api: add Person and StreamingService contexts

This commit is contained in:
Cron1cle
2025-10-07 20:08:51 +02:00
parent de2778d6db
commit 2dacb9fa80
12 changed files with 390 additions and 111 deletions

40
apis/personApi.ts Normal file
View File

@@ -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<PersonHistoryEntry[]> {
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 [];
}
}

View File

@@ -41,14 +41,13 @@ export async function getSeason(
showId: number, showId: number,
seasonNumber: number seasonNumber: number
): Promise<Season | null> { ): Promise<Season | null> {
// WICHTIG: trailing Slash entfernt
const url = `${SEASON_BASE_URL}/${showId}/seasons/${seasonNumber}`; const url = `${SEASON_BASE_URL}/${showId}/seasons/${seasonNumber}`;
try { try {
console.log("[getSeason] Fetch:", url); console.log("[getSeason] Fetch:", url);
const res = await fetch(url); const res = await fetch(url);
console.log("[getSeason] Status:", res.status); console.log("[getSeason] Status:", res.status);
if (res.status === 404) return null; 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 raw: RawSeason = await res.json();
const participants: SeasonParticipant[] = raw.seasonParticipants.map( const participants: SeasonParticipant[] = raw.seasonParticipants.map(
(p) => ({ (p) => ({

View File

@@ -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<StreamingServiceRaw[]> {
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;
}
}

View File

@@ -1,6 +1,7 @@
import styles from "@/app/tabStyles/indexStyles"; import styles from "@/app/tabStyles/indexStyles";
import ShowCard from "@/components/ui/ShowCard"; import ShowCard from "@/components/ui/ShowCard";
import { useShowContext } from "@/contexts/ShowContext"; import { useShowContext } from "@/contexts/ShowContext";
import { useStreamingServiceContext } from "@/contexts/StreamingServiceContext";
import { router } from "expo-router"; import { router } from "expo-router";
import React from "react"; import React from "react";
import { ActivityIndicator, Text, View } from "react-native"; import { ActivityIndicator, Text, View } from "react-native";
@@ -11,6 +12,7 @@ import {
export default function HomeScreen() { export default function HomeScreen() {
const { shows, error, loading } = useShowContext(); const { shows, error, loading } = useShowContext();
const { streamingServices } = useStreamingServiceContext();
if (loading) { if (loading) {
return ( return (
@@ -47,6 +49,11 @@ export default function HomeScreen() {
<ScrollView contentContainerStyle={{ paddingBottom: 30 }}> <ScrollView contentContainerStyle={{ paddingBottom: 30 }}>
{shows.map((show) => { {shows.map((show) => {
const showLiveBadge = show.running; const showLiveBadge = show.running;
const streamingService =
streamingServices[
`assets.images.streamingServices.${show.streamingService.toLowerCase()}`
];
return ( return (
<ShowCard <ShowCard
key={show.id} key={show.id}
@@ -66,7 +73,7 @@ export default function HomeScreen() {
}) })
} }
imageUri={show.bannerUri} imageUri={show.bannerUri}
streamingServiceUri={show.streamingService} streamingServiceUri={streamingService}
genres={show.genres} genres={show.genres}
{...(showLiveBadge {...(showLiveBadge
? { ? {

View File

@@ -1,5 +1,7 @@
import { ShowProvider } from "@/contexts/ShowContext"; import { ShowProvider } from "@/contexts/ShowContext";
import { SeasonProvider } from "@/contexts/SeasonContext"; import { SeasonProvider } from "@/contexts/SeasonContext";
import { StreamingServiceProvider } from "@/contexts/StreamingServiceContext";
import { PersonProvider } from "@/contexts/PersonContext";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import "react-native-reanimated"; import "react-native-reanimated";
@@ -7,6 +9,8 @@ export default function RootLayout() {
return ( return (
<ShowProvider> <ShowProvider>
<SeasonProvider> <SeasonProvider>
<StreamingServiceProvider>
<PersonProvider>
<Stack> <Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen <Stack.Screen
@@ -23,6 +27,8 @@ export default function RootLayout() {
}} }}
/> />
</Stack> </Stack>
</PersonProvider>
</StreamingServiceProvider>
</SeasonProvider> </SeasonProvider>
</ShowProvider> </ShowProvider>
); );

View File

@@ -1,80 +1,67 @@
import { View, Image, Text, TouchableOpacity } from "react-native"; import { View, Image, Text, TouchableOpacity } from "react-native";
import styles from "@/app/stackStyles/participantStyles"; import styles from "@/app/stackStyles/participantStyles";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import React, { import React, { useEffect, useMemo, useState } from "react";
useCallback, import { router, useLocalSearchParams } from "expo-router";
useMemo, import { usePersonContext } from "@/contexts/PersonContext";
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 { import {
ScrollView, ScrollView,
GestureHandlerRootView, GestureHandlerRootView,
} from "react-native-gesture-handler"; } from "react-native-gesture-handler";
import { useShowContext } from "@/contexts/ShowContext"; import { useShowContext } from "@/contexts/ShowContext";
import Animated, { import { getPersonHistory } from "@/apis/personApi";
useSharedValue,
useAnimatedStyle,
withTiming,
withRepeat,
withSequence,
Easing,
cancelAnimation,
} from "react-native-reanimated";
export default function ParticipantScreen() { export default function ParticipantScreen() {
const { getPersonAppearances, isLoading, getError } = usePersonContext();
const [appearances, setAppearances] = useState<
{
showId: number;
seasons: number[];
}[]
>([]);
const { shows, error, loading } = useShowContext(); const { shows, error, loading } = useShowContext();
const { participantId, name, season } = useLocalSearchParams();
const bottomSheetRef = useRef<BottomSheet>(null); const numericId = Array.isArray(participantId)
? Number(participantId[0])
const [sheetIndex, setSheetIndex] = useState(1); : Number(participantId);
const handleSheetChange = useCallback((index: number) => {
setSheetIndex(index);
}, []);
const snapPoints = useMemo(() => ["10%", "10%", "45%"], []);
const bounce = useSharedValue(0);
const expanded = useSharedValue(0);
useEffect(() => { useEffect(() => {
if (sheetIndex === 2) { let active = true;
cancelAnimation(bounce); (async () => {
expanded.value = withTiming(1, { duration: 120 }); if (!numericId || Number.isNaN(numericId)) return;
bounce.value = withTiming(-12, { duration: 120 }); const data = await getPersonAppearances(numericId);
} else { if (!active) return;
expanded.value = withTiming(0, { duration: 100 }); const grouped = data.showIds.map((id) => ({
bounce.value = withRepeat( showId: id,
withSequence( seasons: data.byShow[id],
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,
})); }));
setAppearances(grouped);
})();
return () => {
active = false;
};
}, [numericId, getPersonAppearances]);
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 ( return (
<GestureHandlerRootView style={styles.mainContainer}> <GestureHandlerRootView style={styles.mainContainer}>
<ScrollView showsVerticalScrollIndicator={false}> <ScrollView showsVerticalScrollIndicator={false}>
<Text style={styles.participantName}>Calvin Ogara</Text> <Text style={styles.participantName}>
{name ? (Array.isArray(name) ? name[0] : name) : "Teilnehmer"}
</Text>
<TouchableOpacity <TouchableOpacity
style={styles.closeIcon} style={styles.closeIcon}
onPress={() => router.back()} onPress={() => router.back()}
@@ -97,7 +84,21 @@ export default function ParticipantScreen() {
<View style={styles.performedShowsSection}> <View style={styles.performedShowsSection}>
<Text style={styles.performedShowsTitle}>Auftritte:</Text> <Text style={styles.performedShowsTitle}>Auftritte:</Text>
{isLoading(numericId) && (
<Text style={{ color: "white", marginTop: 8 }}>Lädt...</Text>
)}
{getError(numericId) && (
<Text style={{ color: "tomato", marginTop: 8 }}>
{getError(numericId)}
</Text>
)}
{!isLoading(numericId) &&
resolved.length === 0 &&
!getError(numericId) && (
<Text style={{ color: "gray", marginTop: 8 }}>
Keine Einträge.
</Text>
)}
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
@@ -106,38 +107,25 @@ export default function ParticipantScreen() {
marginTop: 15, marginTop: 15,
}} }}
> >
{shows.map((show, i) => ( {resolved.map(({ show, seasons }) => (
<View style={styles.showContainer} key={i}> <TouchableOpacity key={show.id} style={styles.showContainer}>
<Image <Image
source={{ uri: show.thumbnailUri }} source={{ uri: show.thumbnailUri }}
style={styles.showImage} style={styles.showImage}
/> />
</View> <Text style={styles.showTitle} numberOfLines={2}>
{show.title}
</Text>
<Text style={styles.showSeason} numberOfLines={1}>
Staffel
{seasons.length === 1
? ` ${seasons[0]}`
: `n ${seasons.join(", ")}`}
</Text>
</TouchableOpacity>
))} ))}
</ScrollView> </ScrollView>
</View> </View>
<BottomSheet
ref={bottomSheetRef}
index={1}
snapPoints={snapPoints}
enableDynamicSizing={false}
onChange={handleSheetChange}
backgroundStyle={{ backgroundColor: "hsl(221, 39%, 12%)" }}
handleIndicatorStyle={{ backgroundColor: "transparent" }}
>
<BottomSheetScrollView
contentContainerStyle={styles.contentContainer}
>
<Animated.View
style={[
{ alignSelf: "center", marginBottom: 20 },
iconAnimatedStyle,
]}
>
<Feather name="chevrons-up" size={40} color="white" />
</Animated.View>
</BottomSheetScrollView>
</BottomSheet>
</ScrollView> </ScrollView>
</GestureHandlerRootView> </GestureHandlerRootView>
); );

View File

@@ -175,7 +175,10 @@ export default function ShowDetails() {
onPress={() => onPress={() =>
router.push({ router.push({
pathname: "/participant", pathname: "/participant",
params: { participantId: p.id, name: p.name }, params: {
participantId: p.id,
name: p.name,
},
}) })
} }
> >

View File

@@ -89,6 +89,20 @@ const styles = StyleSheet.create({
margin: 6, margin: 6,
backgroundColor: "#eee", 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; export default styles;

View File

@@ -35,7 +35,7 @@ const ShowCard = ({
<View style={styles.streamingServiceIcon}> <View style={styles.streamingServiceIcon}>
<Image <Image
source={{ source={{
uri: "https://play-lh.googleusercontent.com/e8u4F0ED6hDMzmjg5cV_C5Sxrzr3xECniwKCD2Q8QfUeVMVRLG41TrsnqroTE7uxk4E", uri: streamingServiceUri,
}} }}
style={[StyleSheet.absoluteFillObject, { borderRadius: 15 }]} style={[StyleSheet.absoluteFillObject, { borderRadius: 15 }]}
/> />

130
contexts/PersonContext.tsx Normal file
View File

@@ -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<number, number[]>;
showIds: number[];
};
type PersonContextType = {
getPersonAppearances: (personId: number) => Promise<PersonAppearances>;
getShowIds: (personId: number) => Promise<number[]>;
getSeasonsForShow: (personId: number, showId: number) => Promise<number[]>;
isLoading: (personId: number) => boolean;
getError: (personId: number) => string | null;
invalidatePerson: (personId: number) => void;
};
const PersonContext = createContext<PersonContextType | null>(null);
export const PersonProvider = ({ children }: { children: ReactNode }) => {
const [cache, setCache] = useState<Record<number, PersonAppearances>>({});
const [loading, setLoading] = useState<Record<number, boolean>>({});
const [errors, setErrors] = useState<Record<number, string | null>>({});
const buildAppearances = (
entries: PersonHistoryEntry[]
): PersonAppearances => {
const byShow: Record<number, Set<number>> = {};
for (const e of entries) {
if (!byShow[e.showId]) byShow[e.showId] = new Set();
byShow[e.showId].add(e.seasonNumber);
}
const byShowSorted: Record<number, number[]> = 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 (
<PersonContext.Provider
value={{
getPersonAppearances,
getShowIds,
getSeasonsForShow,
isLoading,
getError,
invalidatePerson,
}}
>
{children}
</PersonContext.Provider>
);
};
export const usePersonContext = () => {
const ctx = useContext(PersonContext);
if (!ctx)
throw new Error("usePersonContext must be used within PersonProvider");
return ctx;
};

View File

@@ -1,5 +1,11 @@
import { getSeason, SeasonParticipant } from "@/apis/seasonApi"; 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 = { type SeasonContextType = {
fetchSeasonParticipants: ( fetchSeasonParticipants: (
@@ -11,11 +17,10 @@ type SeasonContextType = {
const SeasonContext = createContext<SeasonContextType | null>(null); const SeasonContext = createContext<SeasonContextType | null>(null);
export const SeasonProvider = ({ children }: { children: React.ReactNode }) => { export const SeasonProvider = ({ children }: { children: ReactNode }) => {
const [seasonCache, setSeasonCache] = useState< const [seasonCache, setSeasonCache] = useState<
Record<string, SeasonParticipant[]> Record<string, SeasonParticipant[]>
>({}); >({});
const [seasonCountCache, setSeasonCountCache] = useState< const [seasonCountCache, setSeasonCountCache] = useState<
Record<number, number> Record<number, number>
>({}); >({});
@@ -24,7 +29,6 @@ export const SeasonProvider = ({ children }: { children: React.ReactNode }) => {
async (showId: number, seasonNumber: number) => { async (showId: number, seasonNumber: number) => {
const key = `${showId}-${seasonNumber}`; const key = `${showId}-${seasonNumber}`;
if (seasonCache[key]) return seasonCache[key]; if (seasonCache[key]) return seasonCache[key];
try { try {
const season = await getSeason(showId, seasonNumber); const season = await getSeason(showId, seasonNumber);
const participants = season?.participants ?? []; const participants = season?.participants ?? [];
@@ -67,8 +71,8 @@ export const SeasonProvider = ({ children }: { children: React.ReactNode }) => {
}; };
export const useSeasonContext = () => { export const useSeasonContext = () => {
const context = useContext(SeasonContext); const ctx = useContext(SeasonContext);
if (!context) if (!ctx)
throw new Error("useSeasonContext must be used within a SeasonProvider"); throw new Error("useSeasonContext must be used within a SeasonProvider");
return context; return ctx;
}; };

View File

@@ -0,0 +1,59 @@
import {
getStreamingImages,
StreamingServiceRaw,
} from "@/apis/streamingServiceApi";
import { createContext, useContext } from "react";
import React from "react";
type StreamingServiceContextType = {
streamingServices: Record<string, string>;
loading: boolean;
error: string | null;
};
const StreamingServiceContext =
createContext<StreamingServiceContextType | null>(null);
export const StreamingServiceProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [streamingServices, setStreamingServices] = React.useState<
Record<string, string>
>({});
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 (
<StreamingServiceContext.Provider
value={{ streamingServices, loading, error }}
>
{children}
</StreamingServiceContext.Provider>
);
};
export const useStreamingServiceContext = () => {
const ctx = useContext(StreamingServiceContext);
if (!ctx)
throw new Error(
"useStreamingServiceContext must be used within a StreamingServiceProvider"
);
return ctx;
};