From 2dacb9fa8059c4726a2276163b7894f7d2ac12a3 Mon Sep 17 00:00:00 2001 From: Cron1cle <118773725+Cron1cle@users.noreply.github.com> Date: Tue, 7 Oct 2025 20:08:51 +0200 Subject: [PATCH] api: add Person and StreamingService contexts --- apis/personApi.ts | 40 +++++++ apis/seasonApi.ts | 3 +- apis/streamingServiceApi.ts | 29 +++++ app/(tabs)/index.tsx | 9 +- app/_layout.tsx | 38 ++++--- app/participant.tsx | 154 ++++++++++++-------------- app/showDetails.tsx | 5 +- app/stackStyles/participantStyles.tsx | 14 +++ components/ui/ShowCard.tsx | 2 +- contexts/PersonContext.tsx | 130 ++++++++++++++++++++++ contexts/SeasonContext.tsx | 18 +-- contexts/StreamingServiceContext.tsx | 59 ++++++++++ 12 files changed, 390 insertions(+), 111 deletions(-) create mode 100644 apis/personApi.ts create mode 100644 apis/streamingServiceApi.ts create mode 100644 contexts/PersonContext.tsx create mode 100644 contexts/StreamingServiceContext.tsx diff --git a/apis/personApi.ts b/apis/personApi.ts new file mode 100644 index 0000000..22c3c2e --- /dev/null +++ b/apis/personApi.ts @@ -0,0 +1,40 @@ +export type PersonHistorySeasonRaw = { + seasonId: number; + show: number; + seasonNumber: number; + startDate?: string; + endDate?: string | null; + seasonParticipants: any[]; +}; + +export type PersonHistoryEntry = { + showId: number; + seasonId: number; + seasonNumber: number; +}; + +const PERSON_API_BASE = "http://45.157.177.99:8080/persons"; + +export async function getPersonHistory( + personId: number +): Promise { + const url = `${PERSON_API_BASE}/${personId}/history`; + try { + console.log("[getPersonHistory] Fetch:", url); + const res = await fetch(url); + if (!res.ok) throw new Error("History fetch failed " + res.status); + const data: unknown = await res.json(); + if (!Array.isArray(data)) { + console.warn("History expected array, got:", data); + return []; + } + return (data as PersonHistorySeasonRaw[]).map((s) => ({ + showId: s.show, + seasonId: s.seasonId, + seasonNumber: s.seasonNumber, + })); + } catch (e) { + console.error("getPersonHistory error:", e); + return []; + } +} diff --git a/apis/seasonApi.ts b/apis/seasonApi.ts index bb87f29..d58e0d1 100644 --- a/apis/seasonApi.ts +++ b/apis/seasonApi.ts @@ -41,14 +41,13 @@ 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}`); + if (!res.ok) throw new Error("Season fetch failed " + res.status); const raw: RawSeason = await res.json(); const participants: SeasonParticipant[] = raw.seasonParticipants.map( (p) => ({ diff --git a/apis/streamingServiceApi.ts b/apis/streamingServiceApi.ts new file mode 100644 index 0000000..f952007 --- /dev/null +++ b/apis/streamingServiceApi.ts @@ -0,0 +1,29 @@ +export type StreamingServiceRaw = { + id: number; + key: string; + value: string; +}; + +const STREAMING_SERVICE_API_URL = "http://45.157.177.99:8080/config"; + +export async function getStreamingImages(): Promise { + try { + const response = await fetch(STREAMING_SERVICE_API_URL); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + const data: unknown = await response.json(); + if (!Array.isArray(data)) { + console.warn("Expected array, got:", data); + return []; + } + return (data as StreamingServiceRaw[]).map((s) => ({ + id: s.id, + key: s.key, + value: s.value, + })); + } catch (error) { + console.error("Fetch error:", error); + throw error; + } +} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index aea0e04..456a913 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,6 +1,7 @@ import styles from "@/app/tabStyles/indexStyles"; import ShowCard from "@/components/ui/ShowCard"; import { useShowContext } from "@/contexts/ShowContext"; +import { useStreamingServiceContext } from "@/contexts/StreamingServiceContext"; import { router } from "expo-router"; import React from "react"; import { ActivityIndicator, Text, View } from "react-native"; @@ -11,6 +12,7 @@ import { export default function HomeScreen() { const { shows, error, loading } = useShowContext(); + const { streamingServices } = useStreamingServiceContext(); if (loading) { return ( @@ -47,6 +49,11 @@ export default function HomeScreen() { {shows.map((show) => { const showLiveBadge = show.running; + const streamingService = + streamingServices[ + `assets.images.streamingServices.${show.streamingService.toLowerCase()}` + ]; + return ( - - - - - + + + + + + + + + ); diff --git a/app/participant.tsx b/app/participant.tsx index 93f1e85..74fbbbe 100644 --- a/app/participant.tsx +++ b/app/participant.tsx @@ -1,80 +1,67 @@ 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 React, { useEffect, useMemo, useState } from "react"; +import { router, useLocalSearchParams } from "expo-router"; +import { usePersonContext } from "@/contexts/PersonContext"; 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"; +import { getPersonHistory } from "@/apis/personApi"; export default function ParticipantScreen() { + const { getPersonAppearances, isLoading, getError } = usePersonContext(); + const [appearances, setAppearances] = useState< + { + showId: number; + seasons: number[]; + }[] + >([]); 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); + const { participantId, name, season } = useLocalSearchParams(); + const numericId = Array.isArray(participantId) + ? Number(participantId[0]) + : Number(participantId); 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 - ); - } + let active = true; + (async () => { + if (!numericId || Number.isNaN(numericId)) return; + const data = await getPersonAppearances(numericId); + if (!active) return; + const grouped = data.showIds.map((id) => ({ + showId: id, + seasons: data.byShow[id], + })); + setAppearances(grouped); + })(); return () => { - cancelAnimation(bounce); + active = false; }; - }, [sheetIndex, bounce, expanded]); + }, [numericId, getPersonAppearances]); - const iconAnimatedStyle = useAnimatedStyle(() => ({ - transform: [ - { translateY: bounce.value }, - { rotate: `${expanded.value * 180}deg` }, - ], - opacity: 1 - expanded.value * 0.3, - })); + const resolved = useMemo( + () => + appearances + .map((a) => { + const show = shows.find((s) => s.id === a.showId); + if (!show) return null; + return { show, seasons: a.seasons }; + }) + .filter( + (v): v is { show: (typeof shows)[number]; seasons: number[] } => !!v + ), + [appearances, shows] + ); return ( - Calvin Ogara + + {name ? (Array.isArray(name) ? name[0] : name) : "Teilnehmer"} + router.back()} @@ -97,7 +84,21 @@ export default function ParticipantScreen() { Auftritte: - + {isLoading(numericId) && ( + Lädt... + )} + {getError(numericId) && ( + + {getError(numericId)} + + )} + {!isLoading(numericId) && + resolved.length === 0 && + !getError(numericId) && ( + + Keine Einträge. + + )} - {shows.map((show, i) => ( - + {resolved.map(({ show, seasons }) => ( + - + + {show.title} + + + Staffel + {seasons.length === 1 + ? ` ${seasons[0]}` + : `n ${seasons.join(", ")}`} + + ))} - - - - - - - ); diff --git a/app/showDetails.tsx b/app/showDetails.tsx index 8b4db37..7edd84d 100644 --- a/app/showDetails.tsx +++ b/app/showDetails.tsx @@ -175,7 +175,10 @@ export default function ShowDetails() { onPress={() => router.push({ pathname: "/participant", - params: { participantId: p.id, name: p.name }, + params: { + participantId: p.id, + name: p.name, + }, }) } > diff --git a/app/stackStyles/participantStyles.tsx b/app/stackStyles/participantStyles.tsx index d349525..f1182e5 100644 --- a/app/stackStyles/participantStyles.tsx +++ b/app/stackStyles/participantStyles.tsx @@ -89,6 +89,20 @@ const styles = StyleSheet.create({ margin: 6, backgroundColor: "#eee", }, + showTitle: { + color: "white", + fontSize: 12, + fontWeight: "600", + textAlign: "center", + marginTop: 10, + }, + showSeason: { + color: "hsl(0, 0%, 80%)", + fontSize: 12, + fontWeight: "400", + textAlign: "center", + marginTop: 3, + }, }); export default styles; diff --git a/components/ui/ShowCard.tsx b/components/ui/ShowCard.tsx index e3df637..ae49032 100644 --- a/components/ui/ShowCard.tsx +++ b/components/ui/ShowCard.tsx @@ -35,7 +35,7 @@ const ShowCard = ({ diff --git a/contexts/PersonContext.tsx b/contexts/PersonContext.tsx new file mode 100644 index 0000000..a4bf3ee --- /dev/null +++ b/contexts/PersonContext.tsx @@ -0,0 +1,130 @@ +import React, { + createContext, + useCallback, + useContext, + useState, + ReactNode, +} from "react"; +import { getPersonHistory, PersonHistoryEntry } from "@/apis/personApi"; + +type PersonAppearances = { + raw: PersonHistoryEntry[]; + byShow: Record; + showIds: number[]; +}; + +type PersonContextType = { + getPersonAppearances: (personId: number) => Promise; + getShowIds: (personId: number) => Promise; + getSeasonsForShow: (personId: number, showId: number) => Promise; + isLoading: (personId: number) => boolean; + getError: (personId: number) => string | null; + invalidatePerson: (personId: number) => void; +}; + +const PersonContext = createContext(null); + +export const PersonProvider = ({ children }: { children: ReactNode }) => { + const [cache, setCache] = useState>({}); + const [loading, setLoading] = useState>({}); + const [errors, setErrors] = useState>({}); + + const buildAppearances = ( + entries: PersonHistoryEntry[] + ): PersonAppearances => { + const byShow: Record> = {}; + for (const e of entries) { + if (!byShow[e.showId]) byShow[e.showId] = new Set(); + byShow[e.showId].add(e.seasonNumber); + } + const byShowSorted: Record = Object.fromEntries( + Object.entries(byShow).map(([showId, seasonsSet]) => [ + Number(showId), + Array.from(seasonsSet).sort((a, b) => a - b), + ]) + ); + return { + raw: entries, + byShow: byShowSorted, + showIds: Object.keys(byShowSorted) + .map(Number) + .sort((a, b) => a - b), + }; + }; + + const fetchAndCache = useCallback(async (personId: number) => { + setLoading((l) => ({ ...l, [personId]: true })); + setErrors((e) => ({ ...e, [personId]: null })); + try { + const data = await getPersonHistory(personId); + const appearances = buildAppearances(data); + setCache((c) => ({ ...c, [personId]: appearances })); + return appearances; + } catch (e: any) { + setErrors((err) => ({ + ...err, + [personId]: e?.message || "Fehler beim Laden", + })); + return { raw: [], byShow: {}, showIds: [] }; + } finally { + setLoading((l) => ({ ...l, [personId]: false })); + } + }, []); + + const getPersonAppearances = useCallback( + async (personId: number) => { + if (cache[personId]) return cache[personId]; + return await fetchAndCache(personId); + }, + [cache, fetchAndCache] + ); + + const getShowIds = useCallback( + async (personId: number) => { + const app = await getPersonAppearances(personId); + return app.showIds; + }, + [getPersonAppearances] + ); + + const getSeasonsForShow = useCallback( + async (personId: number, showId: number) => { + const app = await getPersonAppearances(personId); + return app.byShow[showId] || []; + }, + [getPersonAppearances] + ); + + const isLoading = (personId: number) => !!loading[personId]; + const getError = (personId: number) => errors[personId] || null; + + const invalidatePerson = (personId: number) => { + setCache((c) => { + const copy = { ...c }; + delete copy[personId]; + return copy; + }); + }; + + return ( + + {children} + + ); +}; + +export const usePersonContext = () => { + const ctx = useContext(PersonContext); + if (!ctx) + throw new Error("usePersonContext must be used within PersonProvider"); + return ctx; +}; diff --git a/contexts/SeasonContext.tsx b/contexts/SeasonContext.tsx index 0ea91d0..d31d2e6 100644 --- a/contexts/SeasonContext.tsx +++ b/contexts/SeasonContext.tsx @@ -1,5 +1,11 @@ import { getSeason, SeasonParticipant } from "@/apis/seasonApi"; -import React, { createContext, useContext, useState, useCallback } from "react"; +import React, { + createContext, + useContext, + useState, + useCallback, + ReactNode, +} from "react"; type SeasonContextType = { fetchSeasonParticipants: ( @@ -11,11 +17,10 @@ type SeasonContextType = { const SeasonContext = createContext(null); -export const SeasonProvider = ({ children }: { children: React.ReactNode }) => { +export const SeasonProvider = ({ children }: { children: ReactNode }) => { const [seasonCache, setSeasonCache] = useState< Record >({}); - const [seasonCountCache, setSeasonCountCache] = useState< Record >({}); @@ -24,7 +29,6 @@ export const SeasonProvider = ({ children }: { children: React.ReactNode }) => { 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 ?? []; @@ -67,8 +71,8 @@ export const SeasonProvider = ({ children }: { children: React.ReactNode }) => { }; export const useSeasonContext = () => { - const context = useContext(SeasonContext); - if (!context) + const ctx = useContext(SeasonContext); + if (!ctx) throw new Error("useSeasonContext must be used within a SeasonProvider"); - return context; + return ctx; }; diff --git a/contexts/StreamingServiceContext.tsx b/contexts/StreamingServiceContext.tsx new file mode 100644 index 0000000..23297ff --- /dev/null +++ b/contexts/StreamingServiceContext.tsx @@ -0,0 +1,59 @@ +import { + getStreamingImages, + StreamingServiceRaw, +} from "@/apis/streamingServiceApi"; +import { createContext, useContext } from "react"; +import React from "react"; + +type StreamingServiceContextType = { + streamingServices: Record; + loading: boolean; + error: string | null; +}; + +const StreamingServiceContext = + createContext(null); + +export const StreamingServiceProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [streamingServices, setStreamingServices] = React.useState< + Record + >({}); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + (async () => { + try { + const data: StreamingServiceRaw[] = await getStreamingImages(); + + const mapped = Object.fromEntries(data.map((s) => [s.key, s.value])); + setStreamingServices(mapped); + } catch { + setError("Failed to fetch streaming services"); + } finally { + setLoading(false); + } + })(); + }, []); + + return ( + + {children} + + ); +}; + +export const useStreamingServiceContext = () => { + const ctx = useContext(StreamingServiceContext); + if (!ctx) + throw new Error( + "useStreamingServiceContext must be used within a StreamingServiceProvider" + ); + return ctx; +};