Added full app to personal portfolio

This commit is contained in:
JaysonCleve 2026-03-24 11:31:10 +01:00
commit a0ca117ac8
14 changed files with 10972 additions and 0 deletions

43
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

35
package.json Normal file
View 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
View 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;

View 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' },
});

View 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,
},
});

View 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
View 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', 'AZ'];
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 === 'AZ') 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
View 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
View 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)}`;