Added full app to personal portfolio
This commit is contained in:
commit
a0ca117ac8
14 changed files with 10972 additions and 0 deletions
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
.expo-shared/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
|
||||||
|
# React Native / native builds
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# local environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
expo-debug.log*
|
||||||
|
|
||||||
|
# macOS / editor files
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# test / coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
44
App.js
Normal file
44
App.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { StatusBar } from 'react-native';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
|
||||||
|
import HomeScreen from './src/screens/HomeScreen';
|
||||||
|
import ConcertDetailScreen from './src/screens/ConcertDetailScreen';
|
||||||
|
import EditConcertScreen from './src/screens/EditConcertScreen';
|
||||||
|
import StatsScreen from './src/screens/StatsScreen';
|
||||||
|
import FullscreenImageScreen from './src/screens/FullscreenImageScreen';
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator();
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor="#000000" />
|
||||||
|
<NavigationContainer>
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
animation: 'ios_from_right',
|
||||||
|
contentStyle: { backgroundColor: '#000000' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="Home" component={HomeScreen} />
|
||||||
|
<Stack.Screen name="ConcertDetail" component={ConcertDetailScreen} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="EditConcert"
|
||||||
|
component={EditConcertScreen}
|
||||||
|
options={{ animation: 'slide_from_bottom' }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="Stats" component={StatsScreen} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="FullscreenImage"
|
||||||
|
component={FullscreenImageScreen}
|
||||||
|
options={{ animation: 'fade' }}
|
||||||
|
/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
</NavigationContainer>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
README.md
Normal file
81
README.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# SETLIST
|
||||||
|
|
||||||
|
SETLIST ist eine mobile Konzerttagebuch-App mit React Native und Expo. Die App ist auf eine einfache, visuell starke Offline-Nutzung ausgelegt: Konzerte erfassen, Erinnerungen festhalten, Bilder speichern und persönliche Statistiken ansehen.
|
||||||
|
|
||||||
|
## Projektstatus
|
||||||
|
|
||||||
|
Dieses Repository wird aktuell als öffentliches Portfolio-Projekt gepflegt. Der Fokus liegt auf Produktidee, UI/UX und mobiler App-Architektur.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Konzert-Einträge mit Titel, Artist, Venue, Datum und Genre
|
||||||
|
- Bewertungssystem und persönliche Notizen pro Konzert
|
||||||
|
- Setlist-Tracker für Songs des Abends
|
||||||
|
- Bilder und Ticket-Fotos pro Eintrag
|
||||||
|
- Suche, Sortierung und Genre-Filter
|
||||||
|
- Statistikansicht mit persönlichen Konzertdaten
|
||||||
|
- Vollständig offline mit lokaler Speicherung via AsyncStorage
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- React Native
|
||||||
|
- Expo
|
||||||
|
- React Navigation
|
||||||
|
- AsyncStorage
|
||||||
|
- Expo Image Picker
|
||||||
|
- Reanimated / Gesture Handler
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm
|
||||||
|
- Expo Go auf einem iOS- oder Android-Gerät oder ein lokaler Simulator
|
||||||
|
|
||||||
|
### Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach kannst du die App per Expo Go oder im Emulator starten.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
setlist/
|
||||||
|
├── App.js
|
||||||
|
├── app.json
|
||||||
|
├── src/
|
||||||
|
│ ├── constants/
|
||||||
|
│ │ └── genres.js
|
||||||
|
│ ├── screens/
|
||||||
|
│ │ ├── ConcertDetailScreen.js
|
||||||
|
│ │ ├── EditConcertScreen.js
|
||||||
|
│ │ ├── FullscreenImageScreen.js
|
||||||
|
│ │ ├── HomeScreen.js
|
||||||
|
│ │ └── StatsScreen.js
|
||||||
|
│ └── utils/
|
||||||
|
│ └── storage.js
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hinweise zum öffentlichen Repo
|
||||||
|
|
||||||
|
- Keine API-Keys, Secrets oder produktiven Zugangsdaten sind Teil dieses Repositories.
|
||||||
|
- Die App ist bewusst offline-first aufgebaut und benötigt kein Backend.
|
||||||
|
- Das Repo dient aktuell primär zur Präsentation im Portfolio und kann sich funktional noch weiterentwickeln.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- Export oder Backup-Funktion
|
||||||
|
- Erweiterte Statistikansichten
|
||||||
|
- Verbesserte Medienverwaltung
|
||||||
|
- Optionaler Import externer Konzertdaten
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Aktuell ist keine Open-Source-Lizenz hinterlegt. Alle Rechte bleiben beim Autor, solange keine separate Lizenzdatei ergänzt wird.
|
||||||
45
app.json
Normal file
45
app.json
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "SETLIST",
|
||||||
|
"slug": "setlist",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/splash.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#080808"
|
||||||
|
},
|
||||||
|
"assetBundlePatterns": ["**/*"],
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": false,
|
||||||
|
"bundleIdentifier": "com.yourname.setlist",
|
||||||
|
"buildNumber": "1",
|
||||||
|
"infoPlist": {
|
||||||
|
"NSPhotoLibraryUsageDescription": "SETLIST braucht Zugriff auf deine Fotos, um Konzerttickets und Galeriebilder zu speichern.",
|
||||||
|
"NSCameraUsageDescription": "SETLIST braucht Zugriff auf deine Kamera, um Fotos direkt aufzunehmen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
"backgroundColor": "#080808"
|
||||||
|
},
|
||||||
|
"package": "com.yourname.setlist",
|
||||||
|
"versionCode": 1,
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.READ_EXTERNAL_STORAGE",
|
||||||
|
"android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "SETLIST braucht Zugriff auf deine Fotos, um Konzerttickets und Bilder zu speichern."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
babel.config.js
Normal file
7
babel.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: ['react-native-reanimated/plugin'],
|
||||||
|
};
|
||||||
|
};
|
||||||
8505
package-lock.json
generated
Normal file
8505
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "setlist",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "node_modules/expo/AppEntry.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^14.0.2",
|
||||||
|
"@react-native-async-storage/async-storage": "2.1.2",
|
||||||
|
"@react-native-community/datetimepicker": "^8.3.0",
|
||||||
|
"@react-navigation/native": "^6.1.10",
|
||||||
|
"@react-navigation/native-stack": "^6.9.18",
|
||||||
|
"expo": "^53.0.0",
|
||||||
|
"expo-asset": "~11.1.7",
|
||||||
|
"expo-blur": "~14.1.5",
|
||||||
|
"expo-font": "~13.3.2",
|
||||||
|
"expo-image-picker": "~16.1.4",
|
||||||
|
"expo-linear-gradient": "~14.1.5",
|
||||||
|
"expo-status-bar": "~2.2.3",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-native": "0.79.5",
|
||||||
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
|
"react-native-reanimated": "~3.17.4",
|
||||||
|
"react-native-safe-area-context": "5.4.0",
|
||||||
|
"react-native-screens": "~4.11.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.20.0"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
179
src/constants/genres.js
Normal file
179
src/constants/genres.js
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
export const GENRES = {
|
||||||
|
rock: {
|
||||||
|
label: 'Rock',
|
||||||
|
icon: '🎸',
|
||||||
|
colors: {
|
||||||
|
primary: '#E63946',
|
||||||
|
secondary: '#1D1D1D',
|
||||||
|
accent: '#FF6B6B',
|
||||||
|
background: '#0D0D0D',
|
||||||
|
card: '#1A1A1A',
|
||||||
|
text: '#F1FAEE',
|
||||||
|
textMuted: '#8A8A8A',
|
||||||
|
gradient: ['#E63946', '#8B0000'],
|
||||||
|
ticketBg: '#1A0A0A',
|
||||||
|
},
|
||||||
|
font: 'bold',
|
||||||
|
patternType: 'noise',
|
||||||
|
},
|
||||||
|
pop: {
|
||||||
|
label: 'Pop',
|
||||||
|
icon: '🎤',
|
||||||
|
colors: {
|
||||||
|
primary: '#FF6FD8',
|
||||||
|
secondary: '#3813C2',
|
||||||
|
accent: '#FFD700',
|
||||||
|
background: '#0F0A1E',
|
||||||
|
card: '#1A1230',
|
||||||
|
text: '#FFFFFF',
|
||||||
|
textMuted: '#9B8EC4',
|
||||||
|
gradient: ['#FF6FD8', '#3813C2'],
|
||||||
|
ticketBg: '#160D2A',
|
||||||
|
},
|
||||||
|
font: 'regular',
|
||||||
|
patternType: 'dots',
|
||||||
|
},
|
||||||
|
hiphop: {
|
||||||
|
label: 'Hip-Hop',
|
||||||
|
icon: '🎧',
|
||||||
|
colors: {
|
||||||
|
primary: '#F7C59F',
|
||||||
|
secondary: '#222222',
|
||||||
|
accent: '#FFFFFF',
|
||||||
|
background: '#111111',
|
||||||
|
card: '#1C1C1C',
|
||||||
|
text: '#F7C59F',
|
||||||
|
textMuted: '#666666',
|
||||||
|
gradient: ['#F7C59F', '#D4956A'],
|
||||||
|
ticketBg: '#161616',
|
||||||
|
},
|
||||||
|
font: 'bold',
|
||||||
|
patternType: 'lines',
|
||||||
|
},
|
||||||
|
electronic: {
|
||||||
|
label: 'Electronic',
|
||||||
|
icon: '🎛️',
|
||||||
|
colors: {
|
||||||
|
primary: '#00F5FF',
|
||||||
|
secondary: '#0A0A0F',
|
||||||
|
accent: '#7B2FFF',
|
||||||
|
background: '#050508',
|
||||||
|
card: '#0D0D15',
|
||||||
|
text: '#E0FFFF',
|
||||||
|
textMuted: '#4A6B7A',
|
||||||
|
gradient: ['#00F5FF', '#7B2FFF'],
|
||||||
|
ticketBg: '#080810',
|
||||||
|
},
|
||||||
|
font: 'regular',
|
||||||
|
patternType: 'grid',
|
||||||
|
},
|
||||||
|
jazz: {
|
||||||
|
label: 'Jazz',
|
||||||
|
icon: '🎷',
|
||||||
|
colors: {
|
||||||
|
primary: '#C9A84C',
|
||||||
|
secondary: '#1A1208',
|
||||||
|
accent: '#E8C97A',
|
||||||
|
background: '#0E0B05',
|
||||||
|
card: '#1C1710',
|
||||||
|
text: '#F2E8D5',
|
||||||
|
textMuted: '#7A6A4A',
|
||||||
|
gradient: ['#C9A84C', '#7A5C1E'],
|
||||||
|
ticketBg: '#140F08',
|
||||||
|
},
|
||||||
|
font: 'regular',
|
||||||
|
patternType: 'circles',
|
||||||
|
},
|
||||||
|
metal: {
|
||||||
|
label: 'Metal',
|
||||||
|
icon: '🤘',
|
||||||
|
colors: {
|
||||||
|
primary: '#B0B0B0',
|
||||||
|
secondary: '#0A0A0A',
|
||||||
|
accent: '#FF4500',
|
||||||
|
background: '#050505',
|
||||||
|
card: '#111111',
|
||||||
|
text: '#CCCCCC',
|
||||||
|
textMuted: '#555555',
|
||||||
|
gradient: ['#888888', '#222222'],
|
||||||
|
ticketBg: '#0A0808',
|
||||||
|
},
|
||||||
|
font: 'bold',
|
||||||
|
patternType: 'noise',
|
||||||
|
},
|
||||||
|
classical: {
|
||||||
|
label: 'Klassik',
|
||||||
|
icon: '🎻',
|
||||||
|
colors: {
|
||||||
|
primary: '#D4AF37',
|
||||||
|
secondary: '#1A1410',
|
||||||
|
accent: '#F0E6C0',
|
||||||
|
background: '#0C0A08',
|
||||||
|
card: '#1A1610',
|
||||||
|
text: '#F5EDD5',
|
||||||
|
textMuted: '#8A7A5A',
|
||||||
|
gradient: ['#D4AF37', '#8B7320'],
|
||||||
|
ticketBg: '#120F08',
|
||||||
|
},
|
||||||
|
font: 'regular',
|
||||||
|
patternType: 'lines',
|
||||||
|
},
|
||||||
|
indie: {
|
||||||
|
label: 'Indie',
|
||||||
|
icon: '🌿',
|
||||||
|
colors: {
|
||||||
|
primary: '#7EC8A4',
|
||||||
|
secondary: '#1A2018',
|
||||||
|
accent: '#F4D06F',
|
||||||
|
background: '#0E1210',
|
||||||
|
card: '#171F15',
|
||||||
|
text: '#E8F5E0',
|
||||||
|
textMuted: '#5A7A5A',
|
||||||
|
gradient: ['#7EC8A4', '#3A7A58'],
|
||||||
|
ticketBg: '#0E1510',
|
||||||
|
},
|
||||||
|
font: 'regular',
|
||||||
|
patternType: 'dots',
|
||||||
|
},
|
||||||
|
punk: {
|
||||||
|
label: 'Punk',
|
||||||
|
icon: '⚡',
|
||||||
|
colors: {
|
||||||
|
primary: '#FFDD00',
|
||||||
|
secondary: '#111111',
|
||||||
|
accent: '#FF0055',
|
||||||
|
background: '#0A0A0A',
|
||||||
|
card: '#181818',
|
||||||
|
text: '#FFFFFF',
|
||||||
|
textMuted: '#666666',
|
||||||
|
gradient: ['#FFDD00', '#FF0055'],
|
||||||
|
ticketBg: '#121005',
|
||||||
|
},
|
||||||
|
font: 'bold',
|
||||||
|
patternType: 'grid',
|
||||||
|
},
|
||||||
|
rnb: {
|
||||||
|
label: 'R&B / Soul',
|
||||||
|
icon: '🎶',
|
||||||
|
colors: {
|
||||||
|
primary: '#C084FC',
|
||||||
|
secondary: '#1A0E2A',
|
||||||
|
accent: '#F97316',
|
||||||
|
background: '#0D0815',
|
||||||
|
card: '#180E28',
|
||||||
|
text: '#F3E8FF',
|
||||||
|
textMuted: '#7A5A9A',
|
||||||
|
gradient: ['#C084FC', '#7C3AED'],
|
||||||
|
ticketBg: '#130A1E',
|
||||||
|
},
|
||||||
|
font: 'regular',
|
||||||
|
patternType: 'circles',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GENRE_LIST = Object.entries(GENRES).map(([key, val]) => ({
|
||||||
|
key,
|
||||||
|
...val,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const getGenreTheme = (genreKey) => GENRES[genreKey] || GENRES.rock;
|
||||||
384
src/screens/ConcertDetailScreen.js
Normal file
384
src/screens/ConcertDetailScreen.js
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Image,
|
||||||
|
TouchableOpacity,
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Alert,
|
||||||
|
Share,
|
||||||
|
FlatList,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { getConcertById, deleteConcert } from '../utils/storage';
|
||||||
|
import { getGenreTheme } from '../constants/genres';
|
||||||
|
|
||||||
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export default function ConcertDetailScreen({ navigation, route }) {
|
||||||
|
const { id } = route.params;
|
||||||
|
const [concert, setConcert] = useState(null);
|
||||||
|
const scrollY = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadConcert();
|
||||||
|
}, [id])
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadConcert = async () => {
|
||||||
|
const data = await getConcertById(id);
|
||||||
|
setConcert(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!concert) return <View style={{ flex: 1, backgroundColor: '#080808' }} />;
|
||||||
|
|
||||||
|
const theme = getGenreTheme(concert.genre);
|
||||||
|
const c = theme.colors;
|
||||||
|
|
||||||
|
const formattedDate = concert.date
|
||||||
|
? new Date(concert.date).toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const headerScale = scrollY.interpolate({
|
||||||
|
inputRange: [-100, 0],
|
||||||
|
outputRange: [1.2, 1],
|
||||||
|
extrapolateRight: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerOpacity = scrollY.interpolate({
|
||||||
|
inputRange: [0, 200],
|
||||||
|
outputRange: [1, 0],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const navOpacity = scrollY.interpolate({
|
||||||
|
inputRange: [150, 250],
|
||||||
|
outputRange: [0, 1],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Konzert löschen',
|
||||||
|
'Möchtest du diesen Eintrag wirklich löschen?',
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Löschen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteConcert(id);
|
||||||
|
navigation.goBack();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
try {
|
||||||
|
await Share.share({
|
||||||
|
message: `🎵 ${concert.title || 'Konzert'}\n${concert.artist ? `👤 ${concert.artist}` : ''}\n${concert.venue ? `📍 ${concert.venue}` : ''}\n${formattedDate || ''}\n\nGeteilt via SETLIST App`,
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStars = (rating) =>
|
||||||
|
[1, 2, 3, 4, 5].map((s) => (
|
||||||
|
<Text key={s} style={{ fontSize: 20, color: s <= rating ? c.primary : '#333' }}>
|
||||||
|
★
|
||||||
|
</Text>
|
||||||
|
));
|
||||||
|
|
||||||
|
const renderGalleryItem = ({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => navigation.navigate('FullscreenImage', { uri: item, images: concert.gallery })}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<Image source={{ uri: item }} style={[styles.galleryThumb, { borderColor: c.primary + '40' }]} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: c.background }]}>
|
||||||
|
{/* Floating Nav on scroll */}
|
||||||
|
<Animated.View style={[styles.floatingNav, { opacity: navOpacity }]}>
|
||||||
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
<Text style={[styles.floatingNavTitle, { color: c.text }]} numberOfLines={1}>
|
||||||
|
{concert.title}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Back / Actions */}
|
||||||
|
<View style={styles.navBar}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.navBtn} activeOpacity={0.7}>
|
||||||
|
<BlurView intensity={60} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.navRight}>
|
||||||
|
<TouchableOpacity onPress={handleShare} style={styles.navBtn} activeOpacity={0.7}>
|
||||||
|
<BlurView intensity={60} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
<Ionicons name="share-outline" size={20} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => navigation.navigate('EditConcert', { id: concert.id })}
|
||||||
|
style={styles.navBtn}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<BlurView intensity={60} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
<Ionicons name="pencil-outline" size={20} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
|
||||||
|
useNativeDriver: true,
|
||||||
|
})}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
>
|
||||||
|
{/* Hero Image */}
|
||||||
|
<Animated.View style={[styles.heroContainer, { transform: [{ scale: headerScale }] }]}>
|
||||||
|
{concert.ticketImage ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
navigation.navigate('FullscreenImage', {
|
||||||
|
uri: concert.ticketImage,
|
||||||
|
images: [concert.ticketImage],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
activeOpacity={0.95}
|
||||||
|
>
|
||||||
|
<Image source={{ uri: concert.ticketImage }} style={styles.heroImage} resizeMode="cover" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={c.gradient}
|
||||||
|
style={styles.heroPlaceholder}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.heroEmoji}>{theme.icon}</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
)}
|
||||||
|
<Animated.View style={[styles.heroOverlay, { opacity: headerOpacity }]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', c.background]}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={[styles.content, { backgroundColor: c.background }]}>
|
||||||
|
{/* Genre + Date tag */}
|
||||||
|
<View style={styles.tagsRow}>
|
||||||
|
<View style={[styles.tag, { backgroundColor: c.primary + '30', borderColor: c.primary + '60' }]}>
|
||||||
|
<Text style={[styles.tagText, { color: c.primary }]}>
|
||||||
|
{theme.icon} {theme.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{formattedDate && (
|
||||||
|
<View style={[styles.tag, { backgroundColor: '#FFFFFF10', borderColor: '#FFFFFF20' }]}>
|
||||||
|
<Ionicons name="calendar-outline" size={11} color={c.textMuted} />
|
||||||
|
<Text style={[styles.tagText, { color: c.textMuted }]}>{formattedDate}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text style={[styles.title, { color: c.text }]}>{concert.title || 'Unbenannt'}</Text>
|
||||||
|
|
||||||
|
{/* Artist */}
|
||||||
|
{concert.artist && (
|
||||||
|
<Text style={[styles.artist, { color: c.primary }]}>{concert.artist}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Venue */}
|
||||||
|
{concert.venue && (
|
||||||
|
<View style={styles.venueRow}>
|
||||||
|
<Ionicons name="location" size={16} color={c.textMuted} />
|
||||||
|
<Text style={[styles.venue, { color: c.textMuted }]}>{concert.venue}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
{concert.rating > 0 && (
|
||||||
|
<View style={styles.ratingRow}>
|
||||||
|
{renderStars(concert.rating)}
|
||||||
|
<Text style={[styles.ratingLabel, { color: c.textMuted }]}>
|
||||||
|
{['', 'Enttäuschend', 'Okay', 'Gut', 'Sehr gut', 'Unvergesslich'][concert.rating]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={[c.primary + '60', 'transparent']}
|
||||||
|
style={styles.divider}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{concert.notes && concert.notes.trim() !== '' && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: c.textMuted }]}>Erinnerungen</Text>
|
||||||
|
<Text style={[styles.notes, { color: c.text }]}>{concert.notes}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Setlist */}
|
||||||
|
{concert.setlist && concert.setlist.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: c.textMuted }]}>Setlist</Text>
|
||||||
|
{concert.setlist.map((song, i) => (
|
||||||
|
<View key={i} style={styles.setlistItem}>
|
||||||
|
<Text style={[styles.setlistNum, { color: c.primary }]}>{String(i + 1).padStart(2, '0')}</Text>
|
||||||
|
<Text style={[styles.setlistSong, { color: c.text }]}>{song}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gallery */}
|
||||||
|
{concert.gallery && concert.gallery.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: c.textMuted }]}>Galerie</Text>
|
||||||
|
<Text style={[styles.sectionCount, { color: c.primary }]}>
|
||||||
|
{concert.gallery.length} Fotos
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={concert.gallery}
|
||||||
|
keyExtractor={(item, i) => i.toString()}
|
||||||
|
renderItem={renderGalleryItem}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.galleryList}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<TouchableOpacity onPress={handleDelete} style={styles.deleteBtn} activeOpacity={0.7}>
|
||||||
|
<Ionicons name="trash-outline" size={16} color="#FF4444" />
|
||||||
|
<Text style={styles.deleteBtnText}>Eintrag löschen</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={{ height: 60 }} />
|
||||||
|
</View>
|
||||||
|
</Animated.ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
floatingNav: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 90,
|
||||||
|
zIndex: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingBottom: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
floatingNavTitle: { fontSize: 16, fontWeight: '700', letterSpacing: 0.5 },
|
||||||
|
navBar: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 52,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
zIndex: 30,
|
||||||
|
},
|
||||||
|
navBtn: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: 'hidden',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
},
|
||||||
|
navRight: { flexDirection: 'row', gap: 10 },
|
||||||
|
heroContainer: { height: 380, overflow: 'hidden' },
|
||||||
|
heroImage: { width: '100%', height: '100%' },
|
||||||
|
heroPlaceholder: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
heroEmoji: { fontSize: 100 },
|
||||||
|
heroOverlay: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 120 },
|
||||||
|
content: { flex: 1, paddingHorizontal: 20, paddingTop: 16 },
|
||||||
|
tagsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 12 },
|
||||||
|
tag: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
tagText: { fontSize: 12, fontWeight: '600' },
|
||||||
|
title: { fontSize: 32, fontWeight: '900', letterSpacing: -0.5, marginBottom: 4, lineHeight: 38 },
|
||||||
|
artist: { fontSize: 18, fontWeight: '700', letterSpacing: 0.3, marginBottom: 8 },
|
||||||
|
venueRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginBottom: 16 },
|
||||||
|
venue: { fontSize: 14 },
|
||||||
|
ratingRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 16 },
|
||||||
|
ratingLabel: { fontSize: 13, marginLeft: 4 },
|
||||||
|
divider: { height: 1, marginBottom: 24 },
|
||||||
|
section: { marginBottom: 28 },
|
||||||
|
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
|
||||||
|
sectionTitle: { fontSize: 11, fontWeight: '700', letterSpacing: 2, textTransform: 'uppercase', marginBottom: 12 },
|
||||||
|
sectionCount: { fontSize: 12, fontWeight: '600' },
|
||||||
|
notes: { fontSize: 16, lineHeight: 26, fontWeight: '400' },
|
||||||
|
setlistItem: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 10 },
|
||||||
|
setlistNum: { fontSize: 12, fontWeight: '800', width: 24 },
|
||||||
|
setlistSong: { fontSize: 16, flex: 1 },
|
||||||
|
galleryList: { gap: 10 },
|
||||||
|
galleryThumb: {
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
deleteBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FF444430',
|
||||||
|
backgroundColor: '#FF44440A',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
deleteBtnText: { color: '#FF4444', fontSize: 14, fontWeight: '600' },
|
||||||
|
});
|
||||||
610
src/screens/EditConcertScreen.js
Normal file
610
src/screens/EditConcertScreen.js
Normal file
|
|
@ -0,0 +1,610 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
TextInput,
|
||||||
|
Image,
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
FlatList,
|
||||||
|
} from 'react-native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
|
import { saveConcert, getConcertById, createConcertId } from '../utils/storage';
|
||||||
|
import { getGenreTheme, GENRE_LIST } from '../constants/genres';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export default function EditConcertScreen({ navigation, route }) {
|
||||||
|
const { id } = route.params;
|
||||||
|
const isEdit = !!id;
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [artist, setArtist] = useState('');
|
||||||
|
const [venue, setVenue] = useState('');
|
||||||
|
const [date, setDate] = useState(new Date());
|
||||||
|
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||||
|
const [genre, setGenre] = useState('rock');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [ticketImage, setTicketImage] = useState(null);
|
||||||
|
const [gallery, setGallery] = useState([]);
|
||||||
|
const [rating, setRating] = useState(0);
|
||||||
|
const [setlistInput, setSetlistInput] = useState('');
|
||||||
|
const [setlist, setSetlist] = useState([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [activeSection, setActiveSection] = useState(null);
|
||||||
|
|
||||||
|
const theme = getGenreTheme(genre);
|
||||||
|
const c = theme.colors;
|
||||||
|
|
||||||
|
const accentAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit) loadConcert();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(accentAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start(() => accentAnim.setValue(0));
|
||||||
|
}, [genre]);
|
||||||
|
|
||||||
|
const loadConcert = async () => {
|
||||||
|
const concert = await getConcertById(id);
|
||||||
|
if (concert) {
|
||||||
|
setTitle(concert.title || '');
|
||||||
|
setArtist(concert.artist || '');
|
||||||
|
setVenue(concert.venue || '');
|
||||||
|
setDate(concert.date ? new Date(concert.date) : new Date());
|
||||||
|
setGenre(concert.genre || 'rock');
|
||||||
|
setNotes(concert.notes || '');
|
||||||
|
setTicketImage(concert.ticketImage || null);
|
||||||
|
setGallery(concert.gallery || []);
|
||||||
|
setRating(concert.rating || 0);
|
||||||
|
setSetlist(concert.setlist || []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickTicketImage = async () => {
|
||||||
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!perm.granted) {
|
||||||
|
Alert.alert('Kein Zugriff', 'Bitte erlaube den Zugriff auf deine Fotos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
quality: 0.8,
|
||||||
|
aspect: [4, 3],
|
||||||
|
});
|
||||||
|
if (!result.canceled && result.assets[0]) {
|
||||||
|
setTicketImage(result.assets[0].uri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickGalleryImages = async () => {
|
||||||
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!perm.granted) return;
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
allowsMultipleSelection: true,
|
||||||
|
quality: 0.7,
|
||||||
|
});
|
||||||
|
if (!result.canceled) {
|
||||||
|
const uris = result.assets.map((a) => a.uri);
|
||||||
|
setGallery((prev) => [...prev, ...uris]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGalleryImage = (index) => {
|
||||||
|
setGallery((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSetlistSong = () => {
|
||||||
|
const song = setlistInput.trim();
|
||||||
|
if (!song) return;
|
||||||
|
setSetlist((prev) => [...prev, song]);
|
||||||
|
setSetlistInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSetlistSong = (i) => {
|
||||||
|
setSetlist((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
Alert.alert('Titel fehlt', 'Bitte gib einen Titel für das Konzert ein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
const concert = {
|
||||||
|
id: id || createConcertId(),
|
||||||
|
title: title.trim(),
|
||||||
|
artist: artist.trim(),
|
||||||
|
venue: venue.trim(),
|
||||||
|
date: date.toISOString(),
|
||||||
|
genre,
|
||||||
|
notes: notes.trim(),
|
||||||
|
ticketImage,
|
||||||
|
gallery,
|
||||||
|
rating,
|
||||||
|
setlist,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
createdAt: isEdit ? undefined : new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const success = await saveConcert(concert);
|
||||||
|
setSaving(false);
|
||||||
|
if (success) {
|
||||||
|
navigation.goBack();
|
||||||
|
} else {
|
||||||
|
Alert.alert('Fehler', 'Das Speichern ist fehlgeschlagen. Bitte versuche es erneut.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionHeader = ({ icon, title: sTitle, section }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.sectionHeader}
|
||||||
|
onPress={() => setActiveSection(activeSection === section ? null : section)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.sectionHeaderLeft}>
|
||||||
|
<View style={[styles.sectionIcon, { backgroundColor: c.primary + '20' }]}>
|
||||||
|
<Ionicons name={icon} size={16} color={c.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.sectionHeaderText, { color: c.text }]}>{sTitle}</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name={activeSection === section ? 'chevron-up' : 'chevron-down'}
|
||||||
|
size={16}
|
||||||
|
color={c.textMuted}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={[styles.container, { backgroundColor: c.background }]}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.headerBtn} activeOpacity={0.7}>
|
||||||
|
<Ionicons name="close" size={22} color={c.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.headerTitle, { color: c.text }]}>
|
||||||
|
{isEdit ? 'Bearbeiten' : 'Neues Konzert'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSave}
|
||||||
|
style={[styles.saveBtn, { backgroundColor: c.primary }]}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveBtnText}>{saving ? '...' : 'Speichern'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Top color accent bar */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={c.gradient}
|
||||||
|
style={styles.accentBar}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.scroll}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* === GENRE PICKER === */}
|
||||||
|
<View style={styles.block}>
|
||||||
|
<Text style={[styles.label, { color: c.textMuted }]}>Genre</Text>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.genreRow}>
|
||||||
|
{GENRE_LIST.map((g) => {
|
||||||
|
const active = genre === g.key;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={g.key}
|
||||||
|
onPress={() => setGenre(g.key)}
|
||||||
|
style={[
|
||||||
|
styles.genreOption,
|
||||||
|
active && {
|
||||||
|
backgroundColor: g.colors.primary,
|
||||||
|
borderColor: g.colors.primary,
|
||||||
|
},
|
||||||
|
!active && { borderColor: '#333', backgroundColor: '#1A1A1A' },
|
||||||
|
]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.genreOptionEmoji}>{g.icon}</Text>
|
||||||
|
<Text style={[styles.genreOptionText, active && { color: '#000' }]}>{g.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === TICKET IMAGE === */}
|
||||||
|
<View style={styles.block}>
|
||||||
|
<Text style={[styles.label, { color: c.textMuted }]}>Ticket / Flyer</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={pickTicketImage}
|
||||||
|
style={[styles.ticketUpload, { borderColor: c.primary + '50', backgroundColor: c.card }]}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{ticketImage ? (
|
||||||
|
<View>
|
||||||
|
<Image source={{ uri: ticketImage }} style={styles.ticketPreview} resizeMode="cover" />
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', c.background]}
|
||||||
|
style={styles.ticketOverlay}
|
||||||
|
start={{ x: 0, y: 0.5 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
<View style={styles.ticketChangeOverlay}>
|
||||||
|
<Ionicons name="camera" size={18} color="#FFF" />
|
||||||
|
<Text style={styles.ticketChangeText}>Ändern</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.ticketPlaceholder}>
|
||||||
|
<LinearGradient colors={c.gradient} style={styles.ticketPlaceholderIcon}>
|
||||||
|
<Ionicons name="ticket-outline" size={28} color="#FFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
<Text style={[styles.ticketPlaceholderText, { color: c.text }]}>
|
||||||
|
Ticket oder Flyer hochladen
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.ticketPlaceholderSub, { color: c.textMuted }]}>
|
||||||
|
Tippe um ein Bild auszuwählen
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === BASIC INFO === */}
|
||||||
|
<View style={styles.block}>
|
||||||
|
<Text style={[styles.label, { color: c.textMuted }]}>Konzertdetails</Text>
|
||||||
|
<View style={[styles.inputGroup, { backgroundColor: c.card, borderColor: c.primary + '30' }]}>
|
||||||
|
<InputRow
|
||||||
|
icon="musical-notes-outline"
|
||||||
|
placeholder="Titel (z.B. Summer Tour 2024)"
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
color={c}
|
||||||
|
/>
|
||||||
|
<Divider color={c.primary + '20'} />
|
||||||
|
<InputRow
|
||||||
|
icon="person-outline"
|
||||||
|
placeholder="Künstler / Band"
|
||||||
|
value={artist}
|
||||||
|
onChangeText={setArtist}
|
||||||
|
color={c}
|
||||||
|
/>
|
||||||
|
<Divider color={c.primary + '20'} />
|
||||||
|
<InputRow
|
||||||
|
icon="location-outline"
|
||||||
|
placeholder="Venue / Ort"
|
||||||
|
value={venue}
|
||||||
|
onChangeText={setVenue}
|
||||||
|
color={c}
|
||||||
|
/>
|
||||||
|
<Divider color={c.primary + '20'} />
|
||||||
|
{/* Date */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowDatePicker(true)}
|
||||||
|
style={styles.dateRow}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="calendar-outline" size={18} color={c.primary} style={{ marginRight: 10 }} />
|
||||||
|
<Text style={[styles.dateText, { color: date ? c.text : c.textMuted }]}>
|
||||||
|
{date
|
||||||
|
? date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||||
|
: 'Datum wählen'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{showDatePicker && (
|
||||||
|
<DateTimePicker
|
||||||
|
value={date}
|
||||||
|
mode="date"
|
||||||
|
display="spinner"
|
||||||
|
onChange={(_, d) => {
|
||||||
|
setShowDatePicker(false);
|
||||||
|
if (d) setDate(d);
|
||||||
|
}}
|
||||||
|
maximumDate={new Date()}
|
||||||
|
themeVariant="dark"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === RATING === */}
|
||||||
|
<View style={styles.block}>
|
||||||
|
<Text style={[styles.label, { color: c.textMuted }]}>Bewertung</Text>
|
||||||
|
<View style={[styles.ratingContainer, { backgroundColor: c.card, borderColor: c.primary + '30' }]}>
|
||||||
|
<View style={styles.starsRow}>
|
||||||
|
{[1, 2, 3, 4, 5].map((s) => (
|
||||||
|
<TouchableOpacity key={s} onPress={() => setRating(s === rating ? 0 : s)} activeOpacity={0.7}>
|
||||||
|
<Text style={[styles.star, { color: s <= rating ? c.primary : '#333', fontSize: 36 }]}>★</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{rating > 0 && (
|
||||||
|
<Text style={[styles.ratingLabel, { color: c.primary }]}>
|
||||||
|
{['', 'Enttäuschend', 'Okay', 'Gut', 'Sehr gut', 'Unvergesslich'][rating]}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === NOTES === */}
|
||||||
|
<View style={styles.block}>
|
||||||
|
<Text style={[styles.label, { color: c.textMuted }]}>Erinnerungen & Notizen</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.notesInput, { backgroundColor: c.card, color: c.text, borderColor: c.primary + '30' }]}
|
||||||
|
placeholder="Was hat dich bewegt? Besondere Momente, Atmosphäre, Highlights..."
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
returnKeyType="default"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === SETLIST === */}
|
||||||
|
<View style={styles.block}>
|
||||||
|
<SectionHeader icon="list-outline" title="Setlist" section="setlist" />
|
||||||
|
{(activeSection === 'setlist' || setlist.length > 0) && (
|
||||||
|
<View style={[styles.setlistContainer, { backgroundColor: c.card, borderColor: c.primary + '30' }]}>
|
||||||
|
{setlist.map((song, i) => (
|
||||||
|
<View key={i} style={[styles.setlistRow, { borderBottomColor: c.primary + '15' }]}>
|
||||||
|
<Text style={[styles.setlistNum, { color: c.primary }]}>{String(i + 1).padStart(2, '0')}</Text>
|
||||||
|
<Text style={[styles.setlistSong, { color: c.text }]}>{song}</Text>
|
||||||
|
<TouchableOpacity onPress={() => removeSetlistSong(i)}>
|
||||||
|
<Ionicons name="close-circle" size={16} color={c.textMuted} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View style={styles.setlistInputRow}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.setlistInput, { color: c.text }]}
|
||||||
|
placeholder="Song hinzufügen..."
|
||||||
|
placeholderTextColor={c.textMuted}
|
||||||
|
value={setlistInput}
|
||||||
|
onChangeText={setSetlistInput}
|
||||||
|
onSubmitEditing={addSetlistSong}
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={addSetlistSong}
|
||||||
|
style={[styles.setlistAddBtn, { backgroundColor: c.primary }]}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={18} color="#000" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* === GALLERY === */}
|
||||||
|
<View style={styles.block}>
|
||||||
|
<SectionHeader icon="images-outline" title={`Galerie (${gallery.length})`} section="gallery" />
|
||||||
|
{(activeSection === 'gallery' || gallery.length > 0) && (
|
||||||
|
<View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.galleryScroll}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={pickGalleryImages}
|
||||||
|
style={[styles.galleryAdd, { borderColor: c.primary + '60', backgroundColor: c.card }]}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={28} color={c.primary} />
|
||||||
|
<Text style={[styles.galleryAddText, { color: c.primary }]}>Fotos</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{gallery.map((uri, i) => (
|
||||||
|
<View key={i} style={styles.galleryThumbWrap}>
|
||||||
|
<Image source={{ uri }} style={styles.galleryThumb} />
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => removeGalleryImage(i)}
|
||||||
|
style={styles.galleryRemove}
|
||||||
|
>
|
||||||
|
<Ionicons name="close-circle" size={20} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ height: 60 }} />
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputRow({ icon, placeholder, value, onChangeText, color, multiline }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
<Ionicons name={icon} size={18} color={color.primary} style={{ marginRight: 10 }} />
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { color: color.text }]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={color.textMuted}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
multiline={multiline}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider({ color }) {
|
||||||
|
return <View style={[styles.divider, { backgroundColor: color }]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 56,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
headerBtn: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: { fontSize: 17, fontWeight: '700' },
|
||||||
|
saveBtn: {
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
saveBtnText: { color: '#000', fontWeight: '800', fontSize: 14 },
|
||||||
|
accentBar: { height: 2, marginBottom: 4 },
|
||||||
|
scroll: { padding: 16 },
|
||||||
|
block: { marginBottom: 24 },
|
||||||
|
label: { fontSize: 11, fontWeight: '700', letterSpacing: 2, textTransform: 'uppercase', marginBottom: 10 },
|
||||||
|
genreRow: { gap: 8, paddingRight: 8 },
|
||||||
|
genreOption: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
genreOptionEmoji: { fontSize: 16 },
|
||||||
|
genreOptionText: { color: '#AAA', fontSize: 13, fontWeight: '600' },
|
||||||
|
ticketUpload: {
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
minHeight: 180,
|
||||||
|
},
|
||||||
|
ticketPreview: { width: '100%', height: 220 },
|
||||||
|
ticketOverlay: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 80 },
|
||||||
|
ticketChangeOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 12,
|
||||||
|
right: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
ticketChangeText: { color: '#FFF', fontSize: 13, fontWeight: '600' },
|
||||||
|
ticketPlaceholder: { padding: 32, alignItems: 'center' },
|
||||||
|
ticketPlaceholderIcon: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
ticketPlaceholderText: { fontSize: 16, fontWeight: '700', marginBottom: 4 },
|
||||||
|
ticketPlaceholderSub: { fontSize: 13 },
|
||||||
|
inputGroup: {
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
inputRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14 },
|
||||||
|
input: { flex: 1, fontSize: 15 },
|
||||||
|
divider: { height: 1, marginHorizontal: 16 },
|
||||||
|
dateRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14 },
|
||||||
|
dateText: { fontSize: 15 },
|
||||||
|
ratingContainer: {
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
starsRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
|
||||||
|
star: {},
|
||||||
|
ratingLabel: { fontSize: 14, fontWeight: '700', letterSpacing: 1 },
|
||||||
|
notesInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 24,
|
||||||
|
minHeight: 130,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
sectionHeaderLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||||
|
sectionIcon: { width: 30, height: 30, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
sectionHeaderText: { fontSize: 15, fontWeight: '700' },
|
||||||
|
setlistContainer: { borderRadius: 16, borderWidth: 1, overflow: 'hidden' },
|
||||||
|
setlistRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
setlistNum: { fontSize: 12, fontWeight: '800', width: 24 },
|
||||||
|
setlistSong: { flex: 1, fontSize: 15 },
|
||||||
|
setlistInputRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
setlistInput: { flex: 1, fontSize: 15 },
|
||||||
|
setlistAddBtn: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
galleryScroll: { gap: 10, paddingRight: 8 },
|
||||||
|
galleryAdd: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
galleryAddText: { fontSize: 11, fontWeight: '700', marginTop: 2 },
|
||||||
|
galleryThumbWrap: { position: 'relative' },
|
||||||
|
galleryThumb: { width: 100, height: 100, borderRadius: 12 },
|
||||||
|
galleryRemove: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -6,
|
||||||
|
right: -6,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
121
src/screens/FullscreenImageScreen.js
Normal file
121
src/screens/FullscreenImageScreen.js
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Image,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Dimensions,
|
||||||
|
FlatList,
|
||||||
|
StatusBar,
|
||||||
|
Text,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
|
||||||
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export default function FullscreenImageScreen({ navigation, route }) {
|
||||||
|
const { uri, images = [] } = route.params;
|
||||||
|
const initialIndex = images.indexOf(uri);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(initialIndex >= 0 ? initialIndex : 0);
|
||||||
|
const flatListRef = useRef(null);
|
||||||
|
|
||||||
|
const displayImages = images.length > 0 ? images : [uri];
|
||||||
|
|
||||||
|
const renderItem = ({ item }) => (
|
||||||
|
<View style={styles.imageContainer}>
|
||||||
|
<Image source={{ uri: item }} style={styles.fullImage} resizeMode="contain" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar hidden />
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
data={displayImages}
|
||||||
|
keyExtractor={(item, i) => i.toString()}
|
||||||
|
renderItem={renderItem}
|
||||||
|
horizontal
|
||||||
|
pagingEnabled
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialScrollIndex={currentIndex}
|
||||||
|
getItemLayout={(_, index) => ({ length: width, offset: width * index, index })}
|
||||||
|
onMomentumScrollEnd={(e) => {
|
||||||
|
const index = Math.round(e.nativeEvent.contentOffset.x / width);
|
||||||
|
setCurrentIndex(index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Close */}
|
||||||
|
<TouchableOpacity style={styles.closeBtn} onPress={() => navigation.goBack()} activeOpacity={0.8}>
|
||||||
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
<Ionicons name="close" size={22} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Counter */}
|
||||||
|
{displayImages.length > 1 && (
|
||||||
|
<View style={styles.counter}>
|
||||||
|
<BlurView intensity={60} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
<Text style={styles.counterText}>
|
||||||
|
{currentIndex + 1} / {displayImages.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
|
{displayImages.length > 1 && (
|
||||||
|
<View style={styles.dots}>
|
||||||
|
{displayImages.map((_, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[styles.dot, i === currentIndex ? styles.dotActive : styles.dotInactive]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#000' },
|
||||||
|
imageContainer: { width, height, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
fullImage: { width, height },
|
||||||
|
closeBtn: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 52,
|
||||||
|
right: 20,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: 'hidden',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
},
|
||||||
|
counter: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 56,
|
||||||
|
left: '50%',
|
||||||
|
transform: [{ translateX: -30 }],
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
counterText: { color: '#FFF', fontSize: 14, fontWeight: '600' },
|
||||||
|
dots: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 40,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
dot: { width: 6, height: 6, borderRadius: 3 },
|
||||||
|
dotActive: { backgroundColor: '#FFF', width: 18 },
|
||||||
|
dotInactive: { backgroundColor: 'rgba(255,255,255,0.3)' },
|
||||||
|
});
|
||||||
563
src/screens/HomeScreen.js
Normal file
563
src/screens/HomeScreen.js
Normal file
|
|
@ -0,0 +1,563 @@
|
||||||
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
FlatList,
|
||||||
|
TouchableOpacity,
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Image,
|
||||||
|
StatusBar,
|
||||||
|
TextInput,
|
||||||
|
ScrollView,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { getConcerts } from '../utils/storage';
|
||||||
|
import { getGenreTheme, GENRE_LIST } from '../constants/genres';
|
||||||
|
|
||||||
|
const { width, height } = Dimensions.get('window');
|
||||||
|
const CARD_WIDTH = width - 32;
|
||||||
|
|
||||||
|
const SORT_OPTIONS = ['Neueste', 'Älteste', 'A–Z'];
|
||||||
|
|
||||||
|
export default function HomeScreen({ navigation }) {
|
||||||
|
const [concerts, setConcerts] = useState([]);
|
||||||
|
const [filtered, setFiltered] = useState([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeGenre, setActiveGenre] = useState(null);
|
||||||
|
const [activeSort, setActiveSort] = useState('Neueste');
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const scrollY = useRef(new Animated.Value(0)).current;
|
||||||
|
const searchAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadConcerts();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadConcerts = async () => {
|
||||||
|
const data = await getConcerts();
|
||||||
|
setConcerts(data);
|
||||||
|
applyFilters(data, search, activeGenre, activeSort);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilters = (data, searchText, genre, sort) => {
|
||||||
|
let result = [...data];
|
||||||
|
if (genre) result = result.filter((c) => c.genre === genre);
|
||||||
|
if (searchText.trim())
|
||||||
|
result = result.filter(
|
||||||
|
(c) =>
|
||||||
|
c.title?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
c.artist?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
c.venue?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
if (sort === 'Neueste') result.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
|
else if (sort === 'Älteste') result.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
|
else if (sort === 'A–Z') result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||||
|
setFiltered(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (text) => {
|
||||||
|
setSearch(text);
|
||||||
|
applyFilters(concerts, text, activeGenre, activeSort);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenreFilter = (key) => {
|
||||||
|
const next = activeGenre === key ? null : key;
|
||||||
|
setActiveGenre(next);
|
||||||
|
applyFilters(concerts, search, next, activeSort);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (sort) => {
|
||||||
|
setActiveSort(sort);
|
||||||
|
applyFilters(concerts, search, activeGenre, sort);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSearch = () => {
|
||||||
|
setShowSearch(!showSearch);
|
||||||
|
Animated.spring(searchAnim, {
|
||||||
|
toValue: showSearch ? 0 : 1,
|
||||||
|
useNativeDriver: true,
|
||||||
|
tension: 100,
|
||||||
|
friction: 10,
|
||||||
|
}).start();
|
||||||
|
if (showSearch) {
|
||||||
|
setSearch('');
|
||||||
|
applyFilters(concerts, '', activeGenre, activeSort);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerOpacity = scrollY.interpolate({
|
||||||
|
inputRange: [0, 80],
|
||||||
|
outputRange: [0, 1],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderConcertCard = ({ item, index }) => {
|
||||||
|
const theme = getGenreTheme(item.genre);
|
||||||
|
const c = theme.colors;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConcertCard
|
||||||
|
concert={item}
|
||||||
|
theme={theme}
|
||||||
|
index={index}
|
||||||
|
onPress={() => navigation.navigate('ConcertDetail', { id: item.id })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmpty = () => (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyEmoji}>🎵</Text>
|
||||||
|
<Text style={styles.emptyTitle}>Noch keine Konzerte</Text>
|
||||||
|
<Text style={styles.emptySubtitle}>
|
||||||
|
Tippe auf + um dein erstes{'\n'}Konzerterlebnis festzuhalten
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderHeader = () => (
|
||||||
|
<View>
|
||||||
|
{/* Genre Filter */}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.genreScroll}
|
||||||
|
style={styles.genreScrollOuter}
|
||||||
|
>
|
||||||
|
{GENRE_LIST.map((g) => {
|
||||||
|
const active = activeGenre === g.key;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={g.key}
|
||||||
|
onPress={() => handleGenreFilter(g.key)}
|
||||||
|
style={[
|
||||||
|
styles.genreChip,
|
||||||
|
active && { backgroundColor: g.colors.primary, borderColor: g.colors.primary },
|
||||||
|
]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.genreChipEmoji}>{g.icon}</Text>
|
||||||
|
<Text style={[styles.genreChipText, active && { color: '#000' }]}>{g.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Sort Row */}
|
||||||
|
<View style={styles.sortRow}>
|
||||||
|
<Text style={styles.concertCount}>
|
||||||
|
{filtered.length} {filtered.length === 1 ? 'Konzert' : 'Konzerte'}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.sortPills}>
|
||||||
|
{SORT_OPTIONS.map((s) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={s}
|
||||||
|
onPress={() => handleSort(s)}
|
||||||
|
style={[styles.sortPill, activeSort === s && styles.sortPillActive]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.sortPillText, activeSort === s && styles.sortPillTextActive]}>
|
||||||
|
{s}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
|
||||||
|
{/* Sticky blur header on scroll */}
|
||||||
|
<Animated.View style={[styles.stickyHeader, { opacity: headerOpacity }]}>
|
||||||
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Top Bar */}
|
||||||
|
<View style={styles.topBar}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.appName}>SETLIST</Text>
|
||||||
|
<Text style={styles.appTagline}>Deine Konzertgeschichte</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.topActions}>
|
||||||
|
<TouchableOpacity onPress={toggleSearch} style={styles.iconBtn} activeOpacity={0.7}>
|
||||||
|
<Ionicons name={showSearch ? 'close' : 'search'} size={22} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => navigation.navigate('Stats')}
|
||||||
|
style={styles.iconBtn}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="bar-chart-outline" size={22} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
{showSearch && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.searchContainer,
|
||||||
|
{
|
||||||
|
opacity: searchAnim,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: searchAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [-10, 0],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="search" size={16} color="#666" style={{ marginRight: 8 }} />
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="Künstler, Venue, Titel..."
|
||||||
|
placeholderTextColor="#555"
|
||||||
|
value={search}
|
||||||
|
onChangeText={handleSearch}
|
||||||
|
autoFocus
|
||||||
|
returnKeyType="search"
|
||||||
|
/>
|
||||||
|
{search.length > 0 && (
|
||||||
|
<TouchableOpacity onPress={() => handleSearch('')}>
|
||||||
|
<Ionicons name="close-circle" size={16} color="#555" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Animated.FlatList
|
||||||
|
data={filtered}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={renderConcertCard}
|
||||||
|
ListHeaderComponent={renderHeader}
|
||||||
|
ListEmptyComponent={renderEmpty}
|
||||||
|
contentContainerStyle={[styles.list, filtered.length === 0 && styles.listEmpty]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
|
||||||
|
useNativeDriver: true,
|
||||||
|
})}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.fab}
|
||||||
|
onPress={() => navigation.navigate('EditConcert', { id: null })}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<LinearGradient colors={['#FF6FD8', '#3813C2']} style={styles.fabGradient} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }}>
|
||||||
|
<Ionicons name="add" size={30} color="#FFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConcertCard({ concert, theme, index, onPress }) {
|
||||||
|
const c = theme.colors;
|
||||||
|
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const onPressIn = () =>
|
||||||
|
Animated.spring(scaleAnim, { toValue: 0.97, useNativeDriver: true, tension: 200, friction: 10 }).start();
|
||||||
|
const onPressOut = () =>
|
||||||
|
Animated.spring(scaleAnim, { toValue: 1, useNativeDriver: true, tension: 200, friction: 10 }).start();
|
||||||
|
|
||||||
|
const formattedDate = concert.date
|
||||||
|
? new Date(concert.date).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={{ transform: [{ scale: scaleAnim }], marginBottom: 16 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
onPressIn={onPressIn}
|
||||||
|
onPressOut={onPressOut}
|
||||||
|
activeOpacity={1}
|
||||||
|
>
|
||||||
|
<View style={[styles.card, { backgroundColor: c.card, borderColor: c.primary + '30' }]}>
|
||||||
|
{/* Ticket Image or Gradient Header */}
|
||||||
|
<View style={styles.cardImageContainer}>
|
||||||
|
{concert.ticketImage ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: concert.ticketImage }}
|
||||||
|
style={styles.cardImage}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LinearGradient
|
||||||
|
colors={c.gradient}
|
||||||
|
style={styles.cardImagePlaceholder}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.cardGenreEmoji}>{theme.icon}</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
)}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', c.card]}
|
||||||
|
style={styles.cardImageOverlay}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0, y: 1 }}
|
||||||
|
/>
|
||||||
|
{/* Genre Badge */}
|
||||||
|
<View style={[styles.genreBadge, { backgroundColor: c.primary }]}>
|
||||||
|
<Text style={styles.genreBadgeText}>{theme.icon} {theme.label}</Text>
|
||||||
|
</View>
|
||||||
|
{/* Rating Stars */}
|
||||||
|
{concert.rating > 0 && (
|
||||||
|
<View style={styles.ratingBadge}>
|
||||||
|
{[1, 2, 3, 4, 5].map((s) => (
|
||||||
|
<Text key={s} style={{ fontSize: 10, color: s <= concert.rating ? c.primary : '#333' }}>
|
||||||
|
★
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Card Content */}
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<Text style={[styles.cardTitle, { color: c.text }]} numberOfLines={1}>
|
||||||
|
{concert.title || 'Unbenannt'}
|
||||||
|
</Text>
|
||||||
|
{concert.artist && (
|
||||||
|
<Text style={[styles.cardArtist, { color: c.primary }]} numberOfLines={1}>
|
||||||
|
{concert.artist}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.cardMeta}>
|
||||||
|
{concert.venue && (
|
||||||
|
<View style={styles.cardMetaItem}>
|
||||||
|
<Ionicons name="location-outline" size={12} color={c.textMuted} />
|
||||||
|
<Text style={[styles.cardMetaText, { color: c.textMuted }]} numberOfLines={1}>
|
||||||
|
{concert.venue}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{formattedDate && (
|
||||||
|
<View style={styles.cardMetaItem}>
|
||||||
|
<Ionicons name="calendar-outline" size={12} color={c.textMuted} />
|
||||||
|
<Text style={[styles.cardMetaText, { color: c.textMuted }]}>{formattedDate}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{/* Gallery preview dots */}
|
||||||
|
{concert.gallery && concert.gallery.length > 0 && (
|
||||||
|
<View style={styles.galleryPreviewRow}>
|
||||||
|
<Ionicons name="images-outline" size={12} color={c.textMuted} />
|
||||||
|
<Text style={[styles.cardMetaText, { color: c.textMuted, marginLeft: 4 }]}>
|
||||||
|
{concert.gallery.length} Fotos
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom accent line */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={c.gradient}
|
||||||
|
style={styles.cardAccentLine}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#080808' },
|
||||||
|
stickyHeader: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 100,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
topBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
appName: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '900',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
letterSpacing: 6,
|
||||||
|
},
|
||||||
|
appTagline: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#555',
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
topActions: { flexDirection: 'row', gap: 8 },
|
||||||
|
iconBtn: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#333',
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
genreScrollOuter: { marginBottom: 4 },
|
||||||
|
genreScroll: { paddingHorizontal: 16, gap: 8, paddingBottom: 12 },
|
||||||
|
genreChip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#333',
|
||||||
|
gap: 5,
|
||||||
|
},
|
||||||
|
genreChipEmoji: { fontSize: 14 },
|
||||||
|
genreChipText: { color: '#AAA', fontSize: 13, fontWeight: '500' },
|
||||||
|
sortRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
concertCount: { color: '#555', fontSize: 13 },
|
||||||
|
sortPills: { flexDirection: 'row', gap: 6 },
|
||||||
|
sortPill: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
|
},
|
||||||
|
sortPillActive: { backgroundColor: '#333' },
|
||||||
|
sortPillText: { color: '#555', fontSize: 12 },
|
||||||
|
sortPillTextActive: { color: '#FFF' },
|
||||||
|
list: { paddingHorizontal: 16, paddingBottom: 120 },
|
||||||
|
listEmpty: { flex: 1 },
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingTop: 80,
|
||||||
|
},
|
||||||
|
emptyEmoji: { fontSize: 60, marginBottom: 16 },
|
||||||
|
emptyTitle: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubtitle: { color: '#555', fontSize: 15, textAlign: 'center', lineHeight: 22 },
|
||||||
|
// Card
|
||||||
|
card: {
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
cardImageContainer: { height: 200, position: 'relative' },
|
||||||
|
cardImage: { width: '100%', height: '100%' },
|
||||||
|
cardImagePlaceholder: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
cardGenreEmoji: { fontSize: 64 },
|
||||||
|
cardImageOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 100,
|
||||||
|
},
|
||||||
|
genreBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
genreBadgeText: { fontSize: 12, fontWeight: '700', color: '#000' },
|
||||||
|
ratingBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 10,
|
||||||
|
gap: 1,
|
||||||
|
},
|
||||||
|
cardContent: { padding: 16 },
|
||||||
|
cardTitle: { fontSize: 20, fontWeight: '800', letterSpacing: 0.5, marginBottom: 2 },
|
||||||
|
cardArtist: { fontSize: 14, fontWeight: '600', letterSpacing: 0.3, marginBottom: 10 },
|
||||||
|
cardMeta: { gap: 4 },
|
||||||
|
cardMetaItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||||
|
cardMetaText: { fontSize: 12 },
|
||||||
|
galleryPreviewRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
cardAccentLine: { height: 3 },
|
||||||
|
fab: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 36,
|
||||||
|
right: 24,
|
||||||
|
borderRadius: 32,
|
||||||
|
shadowColor: '#FF6FD8',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 12,
|
||||||
|
},
|
||||||
|
fabGradient: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
279
src/screens/StatsScreen.js
Normal file
279
src/screens/StatsScreen.js
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
Dimensions,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { getStats, getConcerts } from '../utils/storage';
|
||||||
|
import { getGenreTheme, GENRES } from '../constants/genres';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export default function StatsScreen({ navigation }) {
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [concerts, setConcerts] = useState([]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadStats();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
const s = await getStats();
|
||||||
|
const c = await getConcerts();
|
||||||
|
setStats(s);
|
||||||
|
setConcerts(c);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!stats) return <View style={{ flex: 1, backgroundColor: '#080808' }} />;
|
||||||
|
|
||||||
|
const topGenres = Object.entries(stats.genreCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const maxGenreCount = topGenres.length > 0 ? topGenres[0][1] : 1;
|
||||||
|
|
||||||
|
const topGenreTheme = stats.topGenre ? getGenreTheme(stats.topGenre) : null;
|
||||||
|
|
||||||
|
const recentConcerts = [...concerts]
|
||||||
|
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
const avgRating =
|
||||||
|
concerts.filter((c) => c.rating > 0).length > 0
|
||||||
|
? concerts.filter((c) => c.rating > 0).reduce((sum, c) => sum + c.rating, 0) /
|
||||||
|
concerts.filter((c) => c.rating > 0).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backBtn} activeOpacity={0.7}>
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>Deine Statistiken</Text>
|
||||||
|
<View style={{ width: 40 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
||||||
|
{/* Hero stat */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={topGenreTheme ? topGenreTheme.colors.gradient : ['#FF6FD8', '#3813C2']}
|
||||||
|
style={styles.heroCard}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.heroNumber}>{stats.totalConcerts}</Text>
|
||||||
|
<Text style={styles.heroLabel}>Konzerte erlebt</Text>
|
||||||
|
{stats.firstYear && stats.latestYear && stats.firstYear !== stats.latestYear && (
|
||||||
|
<Text style={styles.heroSub}>
|
||||||
|
{stats.firstYear} — {stats.latestYear}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<StatCard
|
||||||
|
icon="star"
|
||||||
|
value={avgRating > 0 ? avgRating.toFixed(1) : '—'}
|
||||||
|
label="Ø Bewertung"
|
||||||
|
color="#FFD700"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="musical-notes"
|
||||||
|
value={concerts.reduce((s, c) => s + (c.setlist?.length || 0), 0)}
|
||||||
|
label="Songs erfasst"
|
||||||
|
color="#7EC8A4"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="images"
|
||||||
|
value={concerts.reduce((s, c) => s + (c.gallery?.length || 0), 0)}
|
||||||
|
label="Fotos"
|
||||||
|
color="#FF6FD8"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{topGenres.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Top Genres</Text>
|
||||||
|
{topGenres.map(([key, count]) => {
|
||||||
|
const t = getGenreTheme(key);
|
||||||
|
const barWidth = (count / maxGenreCount) * (width - 80);
|
||||||
|
return (
|
||||||
|
<View key={key} style={styles.genreBar}>
|
||||||
|
<View style={styles.genreBarLabel}>
|
||||||
|
<Text style={styles.genreBarEmoji}>{t.icon}</Text>
|
||||||
|
<Text style={styles.genreBarName}>{t.label}</Text>
|
||||||
|
<Text style={[styles.genreBarCount, { color: t.colors.primary }]}>{count}x</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.genreBarTrack}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={t.colors.gradient}
|
||||||
|
style={[styles.genreBarFill, { width: barWidth }]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Years */}
|
||||||
|
{Object.keys(stats.yearCounts).length > 1 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Nach Jahr</Text>
|
||||||
|
<View style={styles.yearGrid}>
|
||||||
|
{Object.entries(stats.yearCounts)
|
||||||
|
.sort((a, b) => b[0] - a[0])
|
||||||
|
.map(([year, count]) => (
|
||||||
|
<View key={year} style={styles.yearCard}>
|
||||||
|
<Text style={styles.yearNum}>{year}</Text>
|
||||||
|
<Text style={styles.yearCount}>{count}</Text>
|
||||||
|
<Text style={styles.yearLabel}>{count === 1 ? 'Konzert' : 'Konzerte'}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent */}
|
||||||
|
{recentConcerts.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Zuletzt besucht</Text>
|
||||||
|
{recentConcerts.map((concert) => {
|
||||||
|
const t = getGenreTheme(concert.genre);
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={concert.id}
|
||||||
|
style={[styles.recentRow, { borderLeftColor: t.colors.primary }]}
|
||||||
|
onPress={() => navigation.navigate('ConcertDetail', { id: concert.id })}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.recentEmoji}>{t.icon}</Text>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.recentTitle} numberOfLines={1}>
|
||||||
|
{concert.title || 'Unbenannt'}
|
||||||
|
</Text>
|
||||||
|
{concert.date && (
|
||||||
|
<Text style={styles.recentDate}>
|
||||||
|
{new Date(concert.date).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ height: 40 }} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon, value, label, color }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.statCard}>
|
||||||
|
<Ionicons name={icon} size={20} color={color} style={{ marginBottom: 6 }} />
|
||||||
|
<Text style={styles.statValue}>{value}</Text>
|
||||||
|
<Text style={styles.statLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#080808' },
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 56,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
backBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
headerTitle: { color: '#FFF', fontSize: 17, fontWeight: '700' },
|
||||||
|
scroll: { padding: 16 },
|
||||||
|
heroCard: {
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
heroNumber: { fontSize: 72, fontWeight: '900', color: '#FFF', lineHeight: 80 },
|
||||||
|
heroLabel: { fontSize: 16, color: 'rgba(255,255,255,0.8)', fontWeight: '600', marginTop: 4 },
|
||||||
|
heroSub: { fontSize: 13, color: 'rgba(255,255,255,0.6)', marginTop: 4 },
|
||||||
|
statsRow: { flexDirection: 'row', gap: 10, marginBottom: 24 },
|
||||||
|
statCard: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
statValue: { color: '#FFF', fontSize: 22, fontWeight: '800', marginBottom: 2 },
|
||||||
|
statLabel: { color: '#555', fontSize: 11, textAlign: 'center' },
|
||||||
|
section: { marginBottom: 28 },
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#555',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
genreBar: { marginBottom: 14 },
|
||||||
|
genreBarLabel: { flexDirection: 'row', alignItems: 'center', marginBottom: 6, gap: 8 },
|
||||||
|
genreBarEmoji: { fontSize: 16 },
|
||||||
|
genreBarName: { color: '#CCC', fontSize: 14, flex: 1 },
|
||||||
|
genreBarCount: { fontSize: 13, fontWeight: '700' },
|
||||||
|
genreBarTrack: {
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
genreBarFill: { height: '100%', borderRadius: 3 },
|
||||||
|
yearGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
||||||
|
yearCard: {
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
minWidth: 90,
|
||||||
|
},
|
||||||
|
yearNum: { color: '#888', fontSize: 13, fontWeight: '600' },
|
||||||
|
yearCount: { color: '#FFF', fontSize: 28, fontWeight: '900' },
|
||||||
|
yearLabel: { color: '#555', fontSize: 11, marginTop: 2 },
|
||||||
|
recentRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: '#1A1A1A',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
},
|
||||||
|
recentEmoji: { fontSize: 20 },
|
||||||
|
recentTitle: { color: '#FFF', fontSize: 15, fontWeight: '600' },
|
||||||
|
recentDate: { color: '#555', fontSize: 12, marginTop: 2 },
|
||||||
|
});
|
||||||
76
src/utils/storage.js
Normal file
76
src/utils/storage.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const CONCERTS_KEY = '@setlist_concerts';
|
||||||
|
const STATS_KEY = '@setlist_stats';
|
||||||
|
|
||||||
|
export const saveConcert = async (concert) => {
|
||||||
|
try {
|
||||||
|
const existing = await getConcerts();
|
||||||
|
const updated = existing.filter((c) => c.id !== concert.id);
|
||||||
|
updated.unshift(concert); // newest first
|
||||||
|
await AsyncStorage.setItem(CONCERTS_KEY, JSON.stringify(updated));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('saveConcert error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConcerts = async () => {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(CONCERTS_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConcertById = async (id) => {
|
||||||
|
const concerts = await getConcerts();
|
||||||
|
return concerts.find((c) => c.id === id) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteConcert = async (id) => {
|
||||||
|
try {
|
||||||
|
const existing = await getConcerts();
|
||||||
|
const updated = existing.filter((c) => c.id !== id);
|
||||||
|
await AsyncStorage.setItem(CONCERTS_KEY, JSON.stringify(updated));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStats = async () => {
|
||||||
|
const concerts = await getConcerts();
|
||||||
|
const genreCounts = {};
|
||||||
|
const yearCounts = {};
|
||||||
|
let totalConcerts = concerts.length;
|
||||||
|
|
||||||
|
concerts.forEach((c) => {
|
||||||
|
// Genre stats
|
||||||
|
if (c.genre) {
|
||||||
|
genreCounts[c.genre] = (genreCounts[c.genre] || 0) + 1;
|
||||||
|
}
|
||||||
|
// Year stats
|
||||||
|
if (c.date) {
|
||||||
|
const year = new Date(c.date).getFullYear();
|
||||||
|
yearCounts[year] = (yearCounts[year] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const topGenre = Object.entries(genreCounts).sort((a, b) => b[1] - a[1])[0];
|
||||||
|
const years = Object.keys(yearCounts).sort();
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalConcerts,
|
||||||
|
genreCounts,
|
||||||
|
yearCounts,
|
||||||
|
topGenre: topGenre ? topGenre[0] : null,
|
||||||
|
firstYear: years[0] || null,
|
||||||
|
latestYear: years[years.length - 1] || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createConcertId = () =>
|
||||||
|
`concert_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
Loading…
Reference in a new issue