diff --git a/apis/personHistoryApi.ts b/apis/personHistoryApi.ts new file mode 100644 index 0000000..eac8e63 --- /dev/null +++ b/apis/personHistoryApi.ts @@ -0,0 +1,169 @@ +export type PersonMini = { + personId: number; + name: string; + birthDate: string | null; + imageUrl?: string | null; +}; + +export type PersonHistoryRecord = { + seasonId: number; + showId: number; + startDate: string | null; + endDate: string | null; + seasonNumber: number; + partner: PersonMini | null; + seasonParticipants: (PersonMini & { partner?: PersonMini | null })[]; +}; + +type RawSeasonNew = { + seasonId: number; + showId?: number; + show?: number; + seasonNumber: number; + startDate: string | null; + endDate: string | null; + partner?: { + personId: number; + name: string; + birthDate?: string | null; + imageUrl?: string | null; + } | null; + seasonParticipants?: { + personId: number; + name: string; + birthDate?: string | null; + imageUrl?: string | null; + }[]; +}; + +type RawPersonOld = { + personId: number; + name: string; + birthDate?: string | null; + imageUrl?: string | null; +}; + +type RawSeasonOld = { + seasonId: number; + show?: number; + showId?: number; + seasonNumber: number; + startDate: string | null; + endDate: string | null; + seasonParticipants?: + | { + id?: { seasonId?: number; personId?: number }; + person?: RawPersonOld | null; + partner?: RawPersonOld | null; + }[] + | null; +}; + +const PERSONS_BASE_URL = "http://45.157.177.99:8080/persons"; + +function toMini(p: any | undefined | null): PersonMini | null { + if (!p || !p.personId || !p.name) return null; + return { + personId: Number(p.personId), + name: String(p.name), + birthDate: p.birthDate ?? null, + imageUrl: p.imageUrl ?? null, + }; +} + +function isFlatSeason(s: any): s is RawSeasonNew { + const sp = s?.seasonParticipants; + return Array.isArray(sp) && (sp.length === 0 || "personId" in (sp[0] ?? {})); +} + +function mapSeason( + s: RawSeasonNew | RawSeasonOld, + requestedPersonId: number +): PersonHistoryRecord { + const showId = Number((s as any).showId ?? (s as any).show ?? 0) || 0; + const base = { + seasonId: s.seasonId, + showId, + startDate: s.startDate ?? null, + endDate: s.endDate ?? null, + seasonNumber: s.seasonNumber, + }; + + if (isFlatSeason(s)) { + const seasonParticipants = Array.isArray(s.seasonParticipants) + ? s.seasonParticipants + .map((p) => toMini(p)) + .filter((x): x is PersonMini => !!x) + : []; + + const partner = toMini(s.partner ?? null); + + return { + ...base, + partner, + + seasonParticipants, + }; + } + + const spOld = (s as RawSeasonOld).seasonParticipants; + const seasonParticipantsOld = Array.isArray(spOld) + ? spOld + .map((p) => { + const pid = p.person?.personId ?? p.id?.personId; + const name = p.person?.name ?? null; + if (!pid || !name) return null; + + const me: PersonMini = { + personId: Number(pid), + name: String(name), + birthDate: p.person?.birthDate ?? null, + imageUrl: p.person?.imageUrl ?? null, + }; + + const partnerMini = toMini(p.partner ?? null); + + return { + ...me, + partner: partnerMini, + }; + }) + .filter((x): x is NonNullable => !!x) + : []; + + const me = + seasonParticipantsOld.find((pp) => pp.personId === requestedPersonId) || + null; + const partner = (me?.partner ?? null) as PersonMini | null; + + return { + ...base, + partner, + seasonParticipants: seasonParticipantsOld, + }; +} + +export async function getPersonHistory( + personId: number, + signal?: AbortSignal +): Promise { + const apiKey = process.env.EXPO_PUBLIC_API_KEY; + const url = `${PERSONS_BASE_URL}/${personId}/history`; + + const res = await fetch(url, { + signal, + headers: { + "Content-Type": "application/json", + "X-API-Key": apiKey ?? "", + }, + }); + + if (!res.ok) throw new Error("GetPersonHistory failed " + res.status); + + const data: unknown = await res.json(); + if (!Array.isArray(data)) return []; + + return (data as (RawSeasonNew | RawSeasonOld)[]).map((s) => + mapSeason(s, personId) + ); +} diff --git a/apis/showApi.ts b/apis/showApi.ts index 6e802e6..a6c8605 100644 --- a/apis/showApi.ts +++ b/apis/showApi.ts @@ -24,7 +24,7 @@ export type Show = { concept: string; startDate?: string; endDate?: string | null; - logoUri: string; + logoUrl: string; running: boolean; }; @@ -58,10 +58,36 @@ export async function getShows(): Promise { streamingService: s.streamingServices, concept: s.concept, running: s.running, - logoUri: s.logoUrl ?? "", + logoUrl: s.logoUrl ?? "", })); } catch (error) { console.error("Fetch error:", error); throw error; } } + +export async function getShowById(showId: number): Promise { + const apiKey = process.env.EXPO_PUBLIC_API_KEY; + const url = `${SHOW_API_URL}/${showId}`; + const res = await fetch(url, { + headers: { + "Content-Type": "application/json", + "X-API-Key": apiKey ?? "", + }, + }); + if (res.status === 404) return null; + if (!res.ok) throw new Error("getShowById failed " + res.status); + const s = (await res.json()) as RawShow; + return { + id: s.showId, + title: s.title, + description: s.description, + genres: s.genre ? s.genre.split(",").map((g) => g.trim()) : [], + thumbnailUri: s.thumbnailUrl, + bannerUri: s.bannerUrl ?? "", + streamingService: s.streamingServices, + concept: s.concept, + running: s.running, + logoUrl: s.logoUrl ?? "", + }; +} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index e643163..930c750 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -176,7 +176,7 @@ export default function HomeScreen() { concept: show.concept, genres: show.genres, streamingService: show.streamingService, - logoUri: show.logoUri, + logoUri: show.logoUrl, running: String(show.running), }, }) diff --git a/app/_layout.tsx b/app/_layout.tsx index a96b23f..da8b329 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -22,7 +22,6 @@ export default function RootLayout() { diff --git a/app/participant.tsx b/app/participant.tsx index 634a8a1..ec2e6fc 100644 --- a/app/participant.tsx +++ b/app/participant.tsx @@ -2,56 +2,188 @@ import styles from "@/app/stackStyles/participantStyles"; import { useShowContext } from "@/contexts/ShowContext"; import Ionicons from "@expo/vector-icons/Ionicons"; import { router, useLocalSearchParams } from "expo-router"; -import React, { useMemo, useState } from "react"; -import { Text, TouchableOpacity, View } from "react-native"; +import React from "react"; +import { Text, TouchableOpacity, View, Image, Dimensions } from "react-native"; +import { + getPersonHistory, + type PersonMini, + type PersonHistoryRecord, +} from "@/apis/personHistoryApi"; +import { getShowById } from "@/apis/showApi"; import { GestureHandlerRootView, ScrollView, } from "react-native-gesture-handler"; -export default function ParticipantScreen() { - const [appearances] = useState< - { - showId: number; - seasons: number[]; - }[] - >([]); - const { shows } = useShowContext(); - const { name } = useLocalSearchParams(); +type SeasonEntry = { + seasonNumber: number; + partner: PersonMini | null; + participants: PersonMini[]; + startDate: string | null; +}; - const resolved = useMemo( - () => - (appearances as any[]) - .map((a) => { - const show = shows.find((s) => s.id === a.showId); - if (!show) return null; +type AppearanceGroup = { + show: { + id: number; + title: string; + bannerUri: string; + thumbnailUri: string; + }; + seasons: SeasonEntry[]; +}; + +export default function ParticipantScreen() { + const { shows } = useShowContext(); + const { name, participantId } = useLocalSearchParams(); + + const pid = Array.isArray(participantId) + ? Number(participantId[0]) + : Number(participantId); + + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const [appearances, setAppearances] = React.useState([]); + + const formatYear = (iso?: string | null) => { + if (!iso) return null; + const [y] = iso.split("-"); + return y || null; + }; + + React.useEffect(() => { + if (!pid || Number.isNaN(pid)) return; + const controller = new AbortController(); + setLoading(true); + setError(null); + + (async () => { + try { + const hist = await getPersonHistory(pid, controller.signal); + + const grouped = new Map>(); + for (const h of hist) { + if (!Number.isFinite(h.showId) || h.showId <= 0) continue; + const seasonsForShow = + grouped.get(h.showId) ?? new Map(); + + const existing = seasonsForShow.get(h.seasonNumber); + if (existing) { + seasonsForShow.set(h.seasonNumber, { + seasonNumber: h.seasonNumber, + partner: existing.partner ?? h.partner ?? null, + participants: existing.participants.length + ? existing.participants + : (h.seasonParticipants ?? []), + startDate: existing.startDate ?? h.startDate ?? null, + }); + } else { + seasonsForShow.set(h.seasonNumber, { + seasonNumber: h.seasonNumber, + partner: h.partner ?? null, + participants: h.seasonParticipants ?? [], + startDate: h.startDate ?? null, + }); + } + + grouped.set(h.showId, seasonsForShow); + } + + const showIds = Array.from(grouped.keys()); + + const fromContext = showIds + .map((id) => shows.find((s) => s.id === id)) + .filter((s): s is (typeof shows)[number] => !!s); + + const missingIds = showIds.filter( + (id) => !fromContext.some((s) => s.id === id) + ); + + const fetched = await Promise.all( + missingIds.map(async (id) => { + try { + const s = await getShowById(id); + return s; + } catch { + return null; + } + }) + ); + + const allShows = [ + ...fromContext, + ...fetched.filter(Boolean), + ] as typeof shows; + + const result: AppearanceGroup[] = allShows.map((s) => { + const seasonsMap = grouped.get(s.id)!; + const seasonsSorted = Array.from(seasonsMap.values()).sort( + (a, b) => a.seasonNumber - b.seasonNumber + ); return { - show, - seasons: a.seasons as number[], - partners: a.partners as { - seasonNumber: number; - partner?: { id: number; name: string; imageUrl?: string | null }; - }[], + show: { + id: s.id, + title: s.title, + bannerUri: s.bannerUri, + thumbnailUri: s.thumbnailUri, + }, + seasons: seasonsSorted, }; - }) - .filter( - ( - v - ): v is { - show: (typeof shows)[number]; - seasons: number[]; - partners: { - seasonNumber: number; - partner?: { id: number; name: string; imageUrl?: string | null }; - }[]; - } => !!v - ), - [appearances, shows] + }); + + result.sort((a, b) => + a.show.title.localeCompare(b.show.title, "de", { + sensitivity: "base", + }) + ); + + setAppearances(result); + } catch (e: any) { + if (!controller.signal.aborted) + setError(e?.message || "Fehler beim Laden"); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + })(); + + return () => controller.abort(); + }, [pid, shows]); + + const [expandedShows, setExpandedShows] = React.useState>( + new Set() + ); + const toggleExpand = React.useCallback((showId: number) => { + setExpandedShows((prev) => { + const next = new Set(prev); + if (next.has(showId)) next.delete(showId); + else next.add(showId); + return next; + }); + }, []); + + const goToShow = React.useCallback((id: number) => { + router.push({ pathname: "/showDetails", params: { id: String(id) } }); + }, []); + + const goToPerson = React.useCallback( + (p: PersonMini) => { + if (!p?.personId) return; + + if (p.personId === pid) return; + router.push({ + pathname: "/participant", + params: { participantId: String(p.personId), name: p.name }, + }); + }, + [pid] ); return ( - + {name} Auftritte: - - - {/* - {resolved.map(({ show, seasons, partners }) => { - const seasonPartnerLines = partners.map((p) => { - const label = `Staffel ${p.seasonNumber}`; - if (!p.partner) return label; - return `${label} • Partner: ${p.partner.name}`; - }); + {appearances.map(({ show, seasons }) => { + const partners = Array.from( + new Map( + seasons + .map((s) => s.partner) + .filter((p): p is NonNullable => !!p) + .map((p) => [p.personId, p]) + ).values() + ); + + const allParticipants = Array.from( + new Map( + seasons + .flatMap((s) => s.participants) + .filter((p) => p.personId !== pid) + .map((p) => [p.personId, p]) + ).values() + ); + + const isExpanded = expandedShows.has(show.id); + const visible = isExpanded + ? allParticipants + : allParticipants.slice(0, 12); + const restCount = Math.max( + allParticipants.length - visible.length, + 0 + ); + return ( - - - + + goToShow(show.id)} + > + + + {show.title} - - - {seasonPartnerLines.join("\n")} + + ({formatYear(seasons[0]?.startDate)}) - + + Staffel {seasons.map((s) => s.seasonNumber).join(" und ")} + + + + + + Weitere Teilnehmer + + + + + {visible.map((p) => ( + goToPerson(p)} + > + + {p.name} + + + ))} + + {!isExpanded && restCount > 0 && ( + toggleExpand(show.id)} + style={styles.moreChip} + > + + +{restCount} mehr + + + )} + + {isExpanded && allParticipants.length > 12 && ( + toggleExpand(show.id)} + style={styles.moreChip} + > + Weniger + + )} + + + + {partners.length > 0 && ( + <> + + + Partner + + + + + {partners.map((p) => ( + + {p.name} + + ))} + + )} + ); })} - */} + diff --git a/app/showDetails.tsx b/app/showDetails.tsx index 8e9e027..ded2a5c 100644 --- a/app/showDetails.tsx +++ b/app/showDetails.tsx @@ -4,7 +4,6 @@ import StackHeader from "@/components/ui/StackHeader"; import { useSeasonContext } from "@/contexts/SeasonContext"; import { router, useLocalSearchParams } from "expo-router"; import React from "react"; - import { Dimensions, Image, @@ -99,6 +98,21 @@ export default function ShowDetails() { }); }, [startDate]); + const handleOpenParticipant = React.useCallback( + (p: { id: number; name: string }) => { + router.push({ + pathname: "/participant", + params: { + participantId: p.id, + name: p.name, + originShowId: String(showId), + originSeason: String(selectedSeason), + }, + }); + }, + [showId, selectedSeason] + ); + return ( @@ -210,23 +224,11 @@ export default function ShowDetails() { styles.participantContainer, { backgroundColor: "hsl(336, 79%, 63%)" }, ]} - onPress={() => - router.push({ - pathname: "/participant", - params: { - participantId: p.id, - name: p.name, - }, - }) - } + onPress={() => handleOpenParticipant(p)} > diff --git a/app/stackStyles/participantStyles.tsx b/app/stackStyles/participantStyles.tsx index d52f13d..4d4727c 100644 --- a/app/stackStyles/participantStyles.tsx +++ b/app/stackStyles/participantStyles.tsx @@ -7,15 +7,15 @@ const styles = StyleSheet.create({ }, closeIcon: { position: "absolute", - top: Dimensions.get("window").height * 0.07, + top: Dimensions.get("window").height * 0.065, right: 15, }, participantName: { color: "white", - fontSize: 24, + fontSize: 20, fontWeight: "600", textAlign: "center", - marginTop: Dimensions.get("window").height * 0.075, + marginTop: Dimensions.get("window").height * 0.06, }, participantImage: { width: "100%", @@ -49,7 +49,7 @@ const styles = StyleSheet.create({ }, performedShowsSection: { width: "100%", - height: Dimensions.get("window").height, + height: "100%", backgroundColor: "hsl(221, 39%, 0%)", marginTop: 20, }, @@ -60,14 +60,7 @@ const styles = StyleSheet.create({ marginTop: 15, marginLeft: 15, }, - showContainer: { - width: "85%", - height: 180, - backgroundColor: "hsl(336, 79%, 63%)", - borderRadius: 10, - alignSelf: "center", - marginTop: 15, - }, + showImage: { width: "100%", height: "100%", @@ -94,14 +87,89 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: "600", textAlign: "center", - marginTop: 10, + marginTop: 15, }, showSeason: { color: "hsl(0, 0%, 80%)", fontSize: 12, fontWeight: "400", textAlign: "center", - marginTop: 3, + marginTop: 5, + }, + showContainer: { + width: Dimensions.get("window").width - 75, + height: 200, + borderRadius: 15, + marginTop: 20, + alignItems: "center", + backgroundColor: "hsl(336, 79%, 63%)", + }, + + card: { + width: Dimensions.get("window").width - 75, + alignItems: "center", + }, + + horizontalLine: { + height: 50, + width: 2, + backgroundColor: "hsl(0, 0%, 70%)", + marginTop: 10, + alignSelf: "center", + }, + partnerLabel: { + color: "hsl(0, 0%, 80%)", + fontSize: 12, + fontWeight: "400", + textAlign: "center", + marginTop: 10, + }, + participantContainer: { + width: "auto", + height: "auto", + borderRadius: 15, + marginTop: 15, + alignItems: "center", + justifyContent: "center", + backgroundColor: "hsl(221, 39%, 12%)", + padding: 10, + }, + + participantLabel: { + color: "white", + fontSize: 12, + }, + participantRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + + alignItems: "center", + justifyContent: "flex-start", + }, + + participantChip: { + paddingVertical: 4, + paddingHorizontal: 8, + borderRadius: 12, + backgroundColor: "hsl(221, 39%, 18%)", + maxWidth: 160, + }, + participantChipText: { + color: "hsl(0, 0%, 85%)", + fontSize: 11, + }, + + moreChip: { + paddingVertical: 4, + paddingHorizontal: 10, + borderRadius: 12, + backgroundColor: "hsl(221, 39%, 28%)", + }, + moreChipText: { + color: "white", + fontSize: 11, + fontWeight: "600", }, });