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