diff --git a/apis/seasonApi.ts b/apis/seasonApi.ts new file mode 100644 index 0000000..bb87f29 --- /dev/null +++ b/apis/seasonApi.ts @@ -0,0 +1,77 @@ +export type RawSeasonParticipant = { + id: { seasonId: number; personId: number }; + person: { + personId: number; + name: string; + birthDate: string; + imageUrl: string | null; + }; + partner: unknown | null; +}; + +export type RawSeason = { + seasonId: number; + show: number; + seasonNumber: number; + startDate?: string; + endDate?: string | null; + moderators: unknown[]; + seasonParticipants: RawSeasonParticipant[]; +}; + +export type SeasonParticipant = { + id: number; + name: string; + birthYear?: number; + imageUri: string; +}; + +export type Season = { + id: number; + showId: number; + seasonNumber: number; + startDate?: string; + endDate?: string | null; + participants: SeasonParticipant[]; +}; + +const SEASON_BASE_URL = "http://45.157.177.99:8080/shows"; + +export async function getSeason( + showId: number, + seasonNumber: number +): Promise { + // WICHTIG: trailing Slash entfernt + const url = `${SEASON_BASE_URL}/${showId}/seasons/${seasonNumber}`; + try { + console.log("[getSeason] Fetch:", url); + const res = await fetch(url); + console.log("[getSeason] Status:", res.status); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`Season fetch failed: ${res.status}`); + const raw: RawSeason = await res.json(); + const participants: SeasonParticipant[] = raw.seasonParticipants.map( + (p) => ({ + id: p.person.personId, + name: p.person.name, + birthYear: p.person.birthDate + ? Number(p.person.birthDate.slice(0, 4)) + : undefined, + imageUri: + p.person.imageUrl ?? + "https://via.placeholder.com/300x400.png?text=No+Image", + }) + ); + return { + id: raw.seasonId, + showId: raw.show, + seasonNumber: raw.seasonNumber, + startDate: raw.startDate, + endDate: raw.endDate, + participants, + }; + } catch (e) { + console.error("getSeason error:", e); + throw e; + } +} diff --git a/apis/showApi.ts b/apis/showApi.ts index 6b88d21..bbea269 100644 --- a/apis/showApi.ts +++ b/apis/showApi.ts @@ -26,11 +26,11 @@ export type Show = { running: boolean; }; -const API_URL = "http://45.157.177.99:8080/shows"; +const SHOW_API_URL = "http://45.157.177.99:8080/shows"; export async function getShows(): Promise { try { - const response = await fetch(API_URL); + const response = await fetch(SHOW_API_URL); if (!response.ok) { throw new Error("Network response was not ok"); } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 72bfa90..aea0e04 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -55,6 +55,7 @@ export default function HomeScreen() { router.push({ pathname: "/showDetails", params: { + id: String(show.id), title: show.title, bannerUri: show.bannerUri, description: show.description, diff --git a/app/_layout.tsx b/app/_layout.tsx index f39f3df..6ab0c8b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,26 +1,29 @@ import { ShowProvider } from "@/contexts/ShowContext"; +import { SeasonProvider } from "@/contexts/SeasonContext"; import { Stack } from "expo-router"; import "react-native-reanimated"; export default function RootLayout() { return ( - - - - - + + + + + + + ); } diff --git a/app/participant.tsx b/app/participant.tsx index 2536ed2..93f1e85 100644 --- a/app/participant.tsx +++ b/app/participant.tsx @@ -1,49 +1,144 @@ import { View, Image, Text, TouchableOpacity } from "react-native"; import styles from "@/app/stackStyles/participantStyles"; import Ionicons from "@expo/vector-icons/Ionicons"; +import React, { + useCallback, + useMemo, + useRef, + useEffect, + useState, +} from "react"; import { router } from "expo-router"; +import Feather from "@expo/vector-icons/Feather"; +import BottomSheet, { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { ScrollView, GestureHandlerRootView, } from "react-native-gesture-handler"; +import { useShowContext } from "@/contexts/ShowContext"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + withRepeat, + withSequence, + Easing, + cancelAnimation, +} from "react-native-reanimated"; export default function ParticipantScreen() { + const { shows, error, loading } = useShowContext(); + + const bottomSheetRef = useRef(null); + + const [sheetIndex, setSheetIndex] = useState(1); + + const handleSheetChange = useCallback((index: number) => { + setSheetIndex(index); + }, []); + + const snapPoints = useMemo(() => ["10%", "10%", "45%"], []); + + const bounce = useSharedValue(0); + const expanded = useSharedValue(0); + + useEffect(() => { + if (sheetIndex === 2) { + cancelAnimation(bounce); + expanded.value = withTiming(1, { duration: 120 }); + bounce.value = withTiming(-12, { duration: 120 }); + } else { + expanded.value = withTiming(0, { duration: 100 }); + bounce.value = withRepeat( + withSequence( + withTiming(-6, { duration: 250, easing: Easing.out(Easing.quad) }), + withTiming(0, { duration: 250, easing: Easing.inOut(Easing.quad) }) + ), + -1, + true + ); + } + return () => { + cancelAnimation(bounce); + }; + }, [sheetIndex, bounce, expanded]); + + const iconAnimatedStyle = useAnimatedStyle(() => ({ + transform: [ + { translateY: bounce.value }, + { rotate: `${expanded.value * 180}deg` }, + ], + opacity: 1 - expanded.value * 0.3, + })); + return ( - Calvin Ogara - router.back()}> - - - - Single - - 24 Jahre - - Köln - - - - - Auftritte: - - + Calvin Ogara + router.back()} > - {[...Array(5)].map((show, index) => ( - - ))} - - + + + + Single + + 24 Jahre + + Köln + + + + + Auftritte: + + + {shows.map((show, i) => ( + + + + ))} + + + + + + + + + + ); } diff --git a/app/showDetails.tsx b/app/showDetails.tsx index e6b3df8..8b4db37 100644 --- a/app/showDetails.tsx +++ b/app/showDetails.tsx @@ -3,6 +3,7 @@ import { useLocalSearchParams, router } from "expo-router"; import ShowInfo from "@/components/ui/ShowInfo"; import ParticipantDetails from "@/components/ParticipantDeatails"; import React from "react"; +import { useSeasonContext } from "@/contexts/SeasonContext"; import { Dimensions, Image, @@ -11,15 +12,58 @@ import { TouchableOpacity, View, } from "react-native"; - +import * as WebBrowser from "expo-web-browser"; import styles from "./stackStyles/showDetailStyles"; -import { parseQueryParams } from "expo-router/build/fork/getStateFromPath-forks"; + export default function ShowDetails() { - const { bannerUri, description, concept, genres, streamingService } = + const { bannerUri, description, concept, genres, streamingService, id } = useLocalSearchParams(); const [selectedParticipants, setSelectedParticipants] = React.useState(true); const [selectedSeason, setSelectedSeason] = React.useState(1); + const showId = Number(id); + const { fetchSeasonParticipants, fetchSeasonCount } = useSeasonContext(); + const [seasonCount, setSeasonCount] = React.useState(0); + const [participants, setParticipants] = React.useState< + { id: number; name: string; imageUri: string }[] + >([]); + const [pLoading, setPLoading] = React.useState(false); + const [pError, setPError] = React.useState(null); + + React.useEffect(() => { + if (!showId) return; + let active = true; + (async () => { + const count = await fetchSeasonCount(showId); + if (active) { + setSeasonCount(count); + if (count > 0 && selectedSeason > count) setSelectedSeason(1); + } + })(); + return () => { + active = false; + }; + }, [showId, fetchSeasonCount]); + + React.useEffect(() => { + if (!showId || !selectedSeason) return; + let active = true; + (async () => { + setPError(null); + setPLoading(true); + try { + const data = await fetchSeasonParticipants(showId, selectedSeason); + if (active) setParticipants(data); + } catch { + if (active) setPError("Fehler beim Laden"); + } finally { + if (active) setPLoading(false); + } + })(); + return () => { + active = false; + }; + }, [showId, selectedSeason, fetchSeasonParticipants]); return ( @@ -37,8 +81,8 @@ export default function ShowDetails() { style={styles.showImage} /> @@ -88,23 +132,25 @@ export default function ShowDetails() { showsHorizontalScrollIndicator={false} contentContainerStyle={styles.seasonList} > - {[...Array(10).keys()].map((season) => ( - setSelectedSeason(season + 1)} - > - {season + 1} - - ))} + {Array.from({ length: seasonCount }, (_, idx) => idx + 1).map( + (season) => ( + setSelectedSeason(season)} + > + {season} + + ) + )} @@ -114,90 +160,38 @@ export default function ShowDetails() { styles.participantSection, ]} > - {[0, 1, 2].map((column) => ( + {pError && ( + + {pError} + + )} + {!pLoading && !pError && participants.length === 0 && ( + Keine Teilnehmer. + )} + {participants.map((p) => ( router.push({ pathname: "/participant", + params: { participantId: p.id, name: p.name }, }) } > - {column === 0 && ( - <> - - - - Calvin Lesra Ogara - - - )} - {column === 1 && ( - <> - - - Sandra Janina - - )} - {column === 2 && ( - <> - - - Kevin Njie - - )} + + + {p.name} + ))} - {[0, 1, 2].map((column) => ( - - {column === 0 && ( - <> - - Single Sidar - - )} - - ))} ) : ( diff --git a/app/stackStyles/participantStyles.tsx b/app/stackStyles/participantStyles.tsx index 7a56628..d349525 100644 --- a/app/stackStyles/participantStyles.tsx +++ b/app/stackStyles/participantStyles.tsx @@ -49,11 +49,10 @@ const styles = StyleSheet.create({ marginTop: 2, }, performedShowsSection: { - marginTop: 0, width: "100%", - height: "100%", - paddingHorizontal: 20, - paddingVertical: 10, + height: 375, + paddingLeft: 15, + paddingBottom: 20, backgroundColor: "hsl(221, 39%, 0%)", }, performedShowsTitle: { @@ -69,6 +68,27 @@ const styles = StyleSheet.create({ borderRadius: 10, marginRight: 15, }, + showImage: { + width: "100%", + height: "100%", + borderRadius: 10, + }, + showLabel: { + color: "white", + fontSize: 14, + fontWeight: "600", + textAlign: "center", + }, + contentContainer: { + flex: 1, + padding: 10, + alignItems: "center", + }, + itemContainer: { + padding: 6, + margin: 6, + backgroundColor: "#eee", + }, }); export default styles; diff --git a/app/stackStyles/showDetailStyles.tsx b/app/stackStyles/showDetailStyles.tsx index cbc6335..2312c2f 100644 --- a/app/stackStyles/showDetailStyles.tsx +++ b/app/stackStyles/showDetailStyles.tsx @@ -68,6 +68,7 @@ const styles = StyleSheet.create({ width: 110, backgroundColor: "hsl(336, 79%, 63%)", borderRadius: 10, + marginTop: 30, }, participantSection: { flexDirection: "row", diff --git a/contexts/SeasonContext.tsx b/contexts/SeasonContext.tsx new file mode 100644 index 0000000..0ea91d0 --- /dev/null +++ b/contexts/SeasonContext.tsx @@ -0,0 +1,74 @@ +import { getSeason, SeasonParticipant } from "@/apis/seasonApi"; +import React, { createContext, useContext, useState, useCallback } from "react"; + +type SeasonContextType = { + fetchSeasonParticipants: ( + showId: number, + seasonNumber: number + ) => Promise; + fetchSeasonCount: (showId: number) => Promise; +}; + +const SeasonContext = createContext(null); + +export const SeasonProvider = ({ children }: { children: React.ReactNode }) => { + const [seasonCache, setSeasonCache] = useState< + Record + >({}); + + const [seasonCountCache, setSeasonCountCache] = useState< + Record + >({}); + + const fetchSeasonParticipants = useCallback( + async (showId: number, seasonNumber: number) => { + const key = `${showId}-${seasonNumber}`; + if (seasonCache[key]) return seasonCache[key]; + + try { + const season = await getSeason(showId, seasonNumber); + const participants = season?.participants ?? []; + setSeasonCache((c) => ({ ...c, [key]: participants })); + return participants; + } catch { + return []; + } + }, + [seasonCache] + ); + + const fetchSeasonCount = useCallback( + async (showId: number) => { + if (seasonCountCache[showId] !== undefined) + return seasonCountCache[showId]; + let n = 0; + for (let s = 1; s <= 50; s++) { + try { + const season = await getSeason(showId, s); + if (!season) break; + n = s; + } catch { + break; + } + } + setSeasonCountCache((c) => ({ ...c, [showId]: n })); + return n; + }, + [seasonCountCache] + ); + + return ( + + {children} + + ); +}; + +export const useSeasonContext = () => { + const context = useContext(SeasonContext); + if (!context) + throw new Error("useSeasonContext must be used within a SeasonProvider"); + return context; +}; diff --git a/package-lock.json b/package-lock.json index a120d34..2f94dfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", + "@gorhom/bottom-sheet": "^5", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", @@ -18,7 +19,7 @@ "expo-constants": "~18.0.9", "expo-font": "~14.0.8", "expo-haptics": "~15.0.7", - "expo-image": "~3.0.8", + "expo-image": "~3.0.9", "expo-linking": "~8.0.8", "expo-router": "~6.0.10", "expo-splash-screen": "~31.0.10", @@ -2265,6 +2266,45 @@ "@babel/highlight": "^7.10.4" } }, + "node_modules/@gorhom/bottom-sheet": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.6.tgz", + "integrity": "sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ==", + "license": "MIT", + "dependencies": { + "@gorhom/portal": "1.0.14", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-native": "*", + "react": "*", + "react-native": "*", + "react-native-gesture-handler": ">=2.16.1", + "react-native-reanimated": ">=3.16.0 || >=4.0.0-" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-native": { + "optional": true + } + } + }, + "node_modules/@gorhom/portal": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz", + "integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -6699,9 +6739,9 @@ } }, "node_modules/expo-image": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.8.tgz", - "integrity": "sha512-L83fTHVjvE5hACxUXPk3dpABteI/IypeqxKMeOAAcT2eB/jbqT53ddsYKEvKAP86eoByQ7+TCtw9AOUizEtaTQ==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-3.0.9.tgz", + "integrity": "sha512-GkPIjeqrODMBdpbRWOzbwiq8ztxjgq1rdZrnqwt/pzQavgXPlr4rW/7aigue9Jm5t5vebhMNAuc1A/XIXXqpcA==", "license": "MIT", "peerDependencies": { "expo": "*", diff --git a/package.json b/package.json index 4e0990c..0c30152 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", + "@gorhom/bottom-sheet": "^5", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", @@ -21,7 +22,7 @@ "expo-constants": "~18.0.9", "expo-font": "~14.0.8", "expo-haptics": "~15.0.7", - "expo-image": "~3.0.8", + "expo-image": "~3.0.9", "expo-linking": "~8.0.8", "expo-router": "~6.0.10", "expo-splash-screen": "~31.0.10",