Merge branch 'rescue-branch'

This commit is contained in:
Yordan Simeonov
2026-03-27 01:08:59 +11:00
26 changed files with 4408 additions and 3026 deletions

0
GEMINI.md Normal file
View File

View File

@@ -7,22 +7,27 @@ export type SearchResultItem = {
const DISCOVER_BASE = "https://fltr-app.de/api/discover/search"; const DISCOVER_BASE = "https://fltr-app.de/api/discover/search";
export async function getSearchResults( export async function discoverSearch(
tags: string[] | string, tags: string[],
limit = 10, signal?: AbortSignal,
signal?: AbortSignal
): Promise<SearchResultItem[]> { ): Promise<SearchResultItem[]> {
const tagList = Array.isArray(tags) ? tags : [tags]; const filteredTags = tags.map((t) => t.trim()).filter(Boolean);
const filteredTags = tagList.map((t) => t.trim()).filter(Boolean);
if (!filteredTags.length) return []; if (!filteredTags.length) return [];
const url = `${DISCOVER_BASE}?tags=${encodeURIComponent( const params = filteredTags
filteredTags.join(",") .map((tag) => `tags=${encodeURIComponent(tag)}`)
)}&limit=${limit}`; .join("&");
const url = `${DISCOVER_BASE}?${params}`;
const apiKey = process.env.EXPO_PUBLIC_API_KEY; const apiKey = process.env.EXPO_PUBLIC_API_KEY;
const res = await fetch(url, { signal, headers: { 'Content-Type': 'application/json', "X-API-Key": apiKey ?? "", } }); const res = await fetch(url, {
if (!res.ok) throw new Error("AutoComplete failed " + res.status); signal,
headers: {
"Content-Type": "application/json",
"X-API-Key": apiKey ?? "",
},
});
if (!res.ok) throw new Error("Discover search failed " + res.status);
const data: unknown = await res.json(); const data: unknown = await res.json();
if (!Array.isArray(data)) return []; if (!Array.isArray(data)) return [];

View File

@@ -1,40 +1,45 @@
import Feather from "@expo/vector-icons/Feather"; import Feather from "@expo/vector-icons/Feather";
import { Tabs } from "expo-router"; import * as Haptics from "expo-haptics";
import React from "react"; import { useNavigation } from "expo-router";
import {
Icon,
Label,
NativeTabs,
VectorIcon,
} from "expo-router/unstable-native-tabs";
import React, { useEffect, useRef } from "react";
export default function TabLayout() { export default function TabLayout() {
const navigation = useNavigation();
const isInitial = useRef(true);
useEffect(() => {
const unsubscribe = navigation.addListener("state", () => {
if (isInitial.current) {
isInitial.current = false;
return;
}
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
});
return unsubscribe;
}, [navigation]);
return ( return (
<Tabs <NativeTabs>
screenOptions={{ <NativeTabs.Trigger name="home">
headerShown: false, <Label>Home</Label>
tabBarActiveTintColor: "#dc2626", <Icon
tabBarStyle: { sf={{ default: "house", selected: "house.fill" }}
backgroundColor: "hsl(221, 39%, 12%)", androidSrc={<VectorIcon family={Feather} name="home" />}
borderTopColor: "#dc2626",
borderTopWidth: 1.5,
paddingTop: 10,
},
tabBarInactiveTintColor: "hsl(0, 0%, 100%)",
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => (
<Feather name="home" size={size} color={color} />
),
}}
/> />
<Tabs.Screen </NativeTabs.Trigger>
name="explore" <NativeTabs.Trigger name="explore">
options={{ <Label>Durchsuchen</Label>
title: "Durchsuchen", <Icon
tabBarIcon: ({ color, size }) => ( sf="magnifyingglass"
<Feather name="search" size={size} color={color} /> androidSrc={<VectorIcon family={Feather} name="search" />}
),
}}
/> />
</Tabs> </NativeTabs.Trigger>
</NativeTabs>
); );
} }

View File

@@ -1,468 +0,0 @@
import { AutoCompleteItem } from "@/apis/autoCompleteApi";
import { Season } from "@/apis/seasonApi";
import { Show } from "@/apis/showApi";
import styles from "@/app/tabStyles/indexStyles";
import ShowBox from "@/components/discovery/ShowBox";
import { useDiscoveryContext } from "@/contexts/DiscoveryContext";
import { FontAwesome } from "@expo/vector-icons";
import Feather from "@expo/vector-icons/Feather";
import React from "react";
import {
Keyboard,
ScrollView,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from "react-native";
import { getShowById } from "@/apis/showApi";
import PersonRow from "@/components/discovery/PersonRow";
import SeasonCarousel from "@/components/discovery/SeasonCarousel";
import TagChip from "@/components/discovery/TagChip";
import { useSearch } from "@/hooks/useSearch";
import {
getIconName,
mapApiPersonToUI,
mapApiSeasonToUI,
mapApiShowToUI,
} from "@/utils/searchMapping";
export default function ExploreScreen() {
const { query, setQuery, suggestions } = useDiscoveryContext();
const [tags, setTags] = React.useState<AutoCompleteItem[]>([]);
const tagStrings = React.useMemo(() => tags.map((t) => t.text), [tags]);
const {
data: results = [],
error,
refetch,
} = useSearch(tagStrings as string[]);
// Lokaler Show-Cache
const [showsById, setShowsById] = React.useState<Record<number, Show>>({});
// Steuerung für Vorschlagsliste
const [showSuggestions, setShowSuggestions] = React.useState(false);
function tagAdded(tag: AutoCompleteItem) {
const nextTags = tags.some((t) => t.text === tag.text) ? tags : [...tags, tag];
setTags(nextTags);
setQuery("");
setShowSuggestions(false);
Keyboard.dismiss();
}
function tagRemoved(tag: AutoCompleteItem) {
const nextTags = tags.filter((t) => t.text !== tag.text);
setTags(nextTags);
}
// Cache mit SHOW-Resultaten füllen
React.useEffect(() => {
const fromResults: Record<number, Show> = {};
for (const r of results) {
if (r.type === "SHOW") {
const uiShow = mapApiShowToUI(r.data);
if (uiShow?.showId != null) fromResults[Number(uiShow.showId)] = uiShow as Show;
}
}
if (Object.keys(fromResults).length) {
setShowsById((prev) => ({ ...prev, ...fromResults }));
}
}, [results]);
// SEASON-Ergebnisse nach showId gruppieren
const seasonsByShowId = React.useMemo(() => {
const map = new Map<number, Season[]>();
for (const r of results) {
if (r.type !== "SEASON") continue;
const s = mapApiSeasonToUI(r.data);
if (!s || s.showId == null) continue;
const key = Number(s.showId);
const list = map.get(key) ?? [];
list.push(s as Season);
map.set(key, list);
}
for (const [k, list] of map) {
list.sort((a, b) => {
const da = a?.startDate ? new Date(a.startDate).getTime() : Number.POSITIVE_INFINITY;
const db = b?.startDate ? new Date(b.startDate).getTime() : Number.POSITIVE_INFINITY;
return da - db;
});
map.set(k, list);
}
return map;
}, [results]);
// Fehlende Shows für Carousels nachladen
React.useEffect(() => {
const needed = Array.from(seasonsByShowId.keys()).filter((id) => !showsById[id]);
if (needed.length === 0) return;
let cancelled = false;
(async () => {
try {
const fetched = await Promise.all(needed.map((id) => getShowById(id)));
if (cancelled) return;
const next: Record<number, Show> = {};
for (const s of fetched) {
if (!s) continue;
next[s.id] = s;
}
if (Object.keys(next).length) {
setShowsById((prev) => ({ ...prev, ...next }));
}
} catch (e) {
console.error(e);
}
})();
return () => {
cancelled = true;
};
}, [seasonsByShowId, showsById]);
// PERSON-Resultate
const persons = React.useMemo(() => {
return results
.filter((r) => r.type === "PERSON")
.map((r) => mapApiPersonToUI(r.data))
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
}, [results]);
// SHOW-Resultate, die KEINE Staffeln haben -> als einzelne ShowBox anzeigen
const standaloneShows: Show[] = React.useMemo(() => {
const seen = new Set<number>();
const list: Show[] = [];
for (const r of results) {
if (r.type !== "SHOW") continue;
const ui = mapApiShowToUI(r.data) as Show | undefined;
if (!ui?.id) continue;
const id = Number(ui.id);
if (seasonsByShowId.has(id)) continue; // bereits als Carousel vorhanden -> nicht doppelt
if (seen.has(id)) continue;
seen.add(id);
list.push(ui);
}
return list;
}, [results, seasonsByShowId]);
// Moderner Fehlerblock
if (error) {
return (
<View
style={[
styles.mainContainer,
{ justifyContent: "center", alignItems: "center", padding: 20 },
]}
>
<View
style={{
alignItems: "center",
gap: 12,
backgroundColor: "rgba(255,255,255,0.05)",
paddingVertical: 24,
paddingHorizontal: 20,
borderRadius: 12,
width: "85%",
}}
>
<Text style={{ fontSize: 36 }}></Text>
<Text
style={{
fontSize: 18,
fontWeight: "600",
color: "white",
textAlign: "center",
}}
>
Fehler beim Laden
</Text>
<Text
style={{
fontSize: 14,
color: "rgba(255,255,255,0.6)",
textAlign: "center",
}}
>
{error?.message || "Ein unerwarteter Fehler ist aufgetreten."}
</Text>
<TouchableOpacity
onPress={() => {
if (typeof refetch === "function") refetch();
}}
style={{
marginTop: 6,
backgroundColor: "rgba(255,255,255,0.15)",
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 8,
}}
>
<Text style={{ color: "white", fontWeight: "600" }}>Erneut versuchen</Text>
</TouchableOpacity>
</View>
</View>
);
}
const noResults =
persons.length === 0 && seasonsByShowId.size === 0 && standaloneShows.length === 0;
return (
<View style={[styles.mainContainer]}>
<View style={styles.header}>
<Text style={[styles.title, { fontSize: 28 }]}>Durchsuchen</Text>
</View>
<TouchableWithoutFeedback
onPress={() => {
Keyboard.dismiss();
setShowSuggestions(false);
}}
>
<View style={{ flex: 1 }}>
<View style={styles.sectionContainer}>
<View style={styles.searchContainer}>
<TextInput
value={query}
autoComplete="off"
autoCorrect={false}
onChangeText={(t) => {
setQuery(t);
if (t.length > 0 && !showSuggestions) setShowSuggestions(true);
if (t.length === 0) setShowSuggestions(false);
}}
placeholder="Wonach suchst du?"
placeholderTextColor=""
style={styles.searchInput}
returnKeyType="search"
onFocus={() => setShowSuggestions(true)}
onSubmitEditing={() => {
if (!query.trim()) return;
tagAdded({ type: "CUSTOM", text: query.trim() });
}}
autoCapitalize="none"
/>
{query.length === 0 ? (
<Feather name="search" size={24} color="hsl(221, 39%, 80%)" />
) : (
<Feather
name="x"
size={24}
color="hsl(221, 39%, 80%)"
onPress={() => {
setQuery("");
setShowSuggestions(false);
}}
/>
)}
</View>
<View style={styles.tagContainer}>
{tags.map((tag) => (
<TagChip
key={tag.text}
icon={getIconName(tag.type)}
label={tag.text}
onPress={() => {
tagRemoved(tag);
}}
/>
))}
</View>
{/* Suggestions dropdown */}
{query.length > 0 && showSuggestions && (
<View style={styles.suggestionsSection}>
<Text style={styles.suggestionTitle}>Suchvorschläge</Text>
<ScrollView keyboardShouldPersistTaps="handled">
{suggestions.map((suggestion, idx) => (
<TouchableOpacity
key={suggestion.text + "_" + idx}
style={styles.suggestionContainer}
onPress={() => tagAdded(suggestion)}
>
<FontAwesome
name={getIconName(suggestion.type)}
size={16}
color="hsl(0, 0%, 90%)"
/>
<Text style={styles.suggestionLabel}>
{suggestion.text}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
)}
</View>
{/* Results */}
<View style={{ flex: 1 }}>
<ScrollView keyboardShouldPersistTaps="handled">
{/* Personen Section (top) */}
{persons.length > 0 && (
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>Personen</Text>
{persons.slice(0, 5).map((p) => (
<PersonRow key={`p-${p.personId ?? p.id}`} person={p} />
))}
</View>
)}
<View style={styles.sectionContainer}>
{(seasonsByShowId.size > 0 || standaloneShows.length > 0) && (
<Text style={styles.sectionTitle}>Staffeln &amp; Shows</Text>
)}
{/* Carousels für Shows mit Staffeln */}
{Array.from(seasonsByShowId.entries()).map(([showId, seasons]) => {
const show = showsById[Number(showId)];
if (!seasons || seasons.length === 0) return null;
if (!show) {
return (
<SeasonCarousel
key={`sc-${showId}`}
show={
{
showId: showId as any,
title: "blaaa",
description: "",
genre: "",
thumbnailUrl: "",
running: false,
} as any
}
seasons={seasons}
renderItem={(s) => (
<ShowBox
show={
{
showId: showId as any,
title: `Show #${showId}`,
description: "",
genre: "",
thumbnailUrl: "",
running: false,
} as any
}
displayedSeason={s}
shadow={false}
/>
)}
/>
);
}
return (
<SeasonCarousel
key={`sc-${showId}`}
show={show}
seasons={seasons}
renderItem={(s) => (
<ShowBox show={show} displayedSeason={s} shadow={false} />
)}
/>
);
})}
{/* Einzelne Shows (ohne Staffeln) */}
{standaloneShows.map((show) => (
<View key={`show-${show.id}`} style={{ marginTop: 12 }}>
<ShowBox show={show} shadow={false} />
</View>
))}
{/* Schöner Empty-State */}
{noResults && (
<View
style={{
alignItems: "center",
justifyContent: "center",
gap: 12,
backgroundColor: "rgba(255,255,255,0.04)",
paddingVertical: 28,
paddingHorizontal: 20,
borderRadius: 12,
marginTop: 8,
}}
>
<Feather
name="search"
size={36}
color="rgba(255,255,255,0.9)"
/>
<Text
style={{
fontSize: 18,
fontWeight: "600",
color: "white",
textAlign: "center",
}}
>
Keine Ergebnisse gefunden
</Text>
<Text
style={{
fontSize: 14,
color: "rgba(255,255,255,0.7)",
textAlign: "center",
}}
>
Passen Sie Ihre Tags an oder setzen Sie die Filter zurück.
</Text>
<View style={{ flexDirection: "row", gap: 10, marginTop: 4 }}>
<TouchableOpacity
onPress={() => {
setTags([]);
setQuery("");
setShowSuggestions(false);
Keyboard.dismiss();
if (typeof refetch === "function") refetch();
}}
style={{
backgroundColor: "rgba(255,255,255,0.15)",
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 8,
}}
>
<Text style={{ color: "white", fontWeight: "600" }}>
Filter zurücksetzen
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setQuery("");
setShowSuggestions(false);
Keyboard.dismiss();
}}
style={{
backgroundColor: "rgba(255,255,255,0.08)",
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 8,
}}
>
<Text style={{ color: "white" }}>Eingabe löschen</Text>
</TouchableOpacity>
</View>
</View>
)}
</View>
</ScrollView>
</View>
</View>
</TouchableWithoutFeedback>
</View>
);
}

View File

@@ -0,0 +1,34 @@
import { Colors } from "@/constants/colors";
import { Stack } from "expo-router";
export default function ExploreLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: Colors.background },
headerTintColor: Colors.text,
headerTitleStyle: {
fontSize: 17,
fontWeight: "600",
},
}}
>
<Stack.Screen
name="index"
options={{
title: "Durchsuchen",
headerLargeTitle: true,
headerLargeTitleStyle: {
color: Colors.text,
fontSize: 28,
},
headerSearchBarOptions: {
placeholder: "Wonach suchst du?",
hideWhenScrolling: false,
autoCapitalize: "none",
},
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,579 @@
import { AutoCompleteItem } from "@/apis/autoCompleteApi";
import { Season } from "@/apis/seasonApi";
import { Show } from "@/apis/showApi";
import { Colors } from "@/constants/colors";
import { useDiscoveryContext } from "@/contexts/DiscoveryContext";
import Feather from "@expo/vector-icons/Feather";
import { Stack } from "expo-router";
import React from "react";
import {
Dimensions,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { getShowById } from "@/apis/showApi";
import PersonRow from "@/components/discovery/PersonRow";
import TagChip from "@/components/discovery/TagChip";
import { useSearch } from "@/hooks/useSearch";
import {
getIconName,
mapApiPersonToUI,
mapApiSeasonToUI,
mapApiShowToUI,
} from "@/utils/searchMapping";
import { router } from "expo-router";
export default function ExploreScreen() {
const { query, setQuery, suggestions } = useDiscoveryContext();
const [tags, setTags] = React.useState<AutoCompleteItem[]>([]);
const tagStrings = React.useMemo(() => tags.map((t) => t.text), [tags]);
const {
data: results = [],
error,
refetch,
} = useSearch(tagStrings as string[]);
// Lokaler Show-Cache
const [showsById, setShowsById] = React.useState<Record<number, Show>>({});
// Steuerung für Vorschlagsliste
const [showSuggestions, setShowSuggestions] = React.useState(false);
function tagAdded(tag: AutoCompleteItem) {
const nextTags = tags.some((t) => t.text === tag.text)
? tags
: [...tags, tag];
setTags(nextTags);
setQuery("");
setShowSuggestions(false);
}
function tagRemoved(tag: AutoCompleteItem) {
const nextTags = tags.filter((t) => t.text !== tag.text);
setTags(nextTags);
}
// Cache mit SHOW-Resultaten füllen
React.useEffect(() => {
const fromResults: Record<number, Show> = {};
for (const r of results) {
if (r.type === "SHOW") {
const uiShow = mapApiShowToUI(r.data);
if (uiShow.showId != null)
fromResults[Number(uiShow.showId)] = uiShow as Show;
}
}
if (Object.keys(fromResults).length) {
setShowsById((prev) => ({ ...prev, ...fromResults }));
}
}, [results]);
// SEASON-Ergebnisse nach showId gruppieren
const seasonsByShowId = React.useMemo(() => {
const map = new Map<number, Season[]>();
for (const r of results) {
if (r.type !== "SEASON") continue;
const s = mapApiSeasonToUI(r.data);
if (!s || s.showId == null) continue;
const key = Number(s.showId);
const list = map.get(key) ?? [];
list.push(s as Season);
map.set(key, list);
}
for (const [k, list] of map) {
list.sort((a, b) => {
const da = a?.startDate
? new Date(a.startDate).getTime()
: Number.POSITIVE_INFINITY;
const db = b?.startDate
? new Date(b.startDate).getTime()
: Number.POSITIVE_INFINITY;
return da - db;
});
map.set(k, list);
}
return map;
}, [results]);
// Fehlende Shows für Carousels nachladen
React.useEffect(() => {
const needed = Array.from(seasonsByShowId.keys()).filter(
(id) => !showsById[id],
);
if (needed.length === 0) return;
let cancelled = false;
(async () => {
try {
const fetched = await Promise.all(needed.map((id) => getShowById(id)));
if (cancelled) return;
const next: Record<number, Show> = {};
for (const s of fetched) {
if (!s) continue;
next[s.id] = s;
}
if (Object.keys(next).length) {
setShowsById((prev) => ({ ...prev, ...next }));
}
} catch (e) {
console.error(e);
}
})();
return () => {
cancelled = true;
};
}, [seasonsByShowId, showsById]);
// PERSON-Resultate
const persons = React.useMemo(() => {
return results
.filter((r) => r.type === "PERSON")
.map((r) => mapApiPersonToUI(r.data))
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
}, [results]);
// SHOW-Resultate: alle Shows (direkte Treffer + aus Seasons) vereint
const unifiedShows = React.useMemo(() => {
const map = new Map<number, { show: Show; seasons: Season[] }>();
// Shows aus Season-Ergebnissen
for (const [showId, seasons] of seasonsByShowId) {
const show = showsById[Number(showId)];
if (show) {
map.set(Number(showId), { show, seasons });
}
}
// Direkte Show-Treffer (ohne Seasons)
for (const r of results) {
if (r.type !== "SHOW") continue;
const uiShow = mapApiShowToUI(r.data);
const id = Number(uiShow.id);
if (!map.has(id)) {
const cachedShow = showsById[id];
map.set(id, { show: cachedShow ?? uiShow, seasons: [] });
}
}
return Array.from(map.values());
}, [results, seasonsByShowId, showsById]);
// Fehlerblock
if (error) {
return (
<View
style={[
s.container,
{ justifyContent: "center", alignItems: "center", padding: 20 },
]}
>
<View style={s.emptyState}>
<Feather
name="alert-triangle"
size={44}
color="rgba(255,255,255,0.2)"
/>
<Text style={s.emptyTitle}>Fehler beim Laden</Text>
<Text style={s.emptySubtitle}>
{error?.message || "Ein unerwarteter Fehler ist aufgetreten."}
</Text>
<TouchableOpacity
onPress={() => {
if (typeof refetch === "function") refetch();
}}
style={s.emptyButton}
activeOpacity={0.7}
>
<Text style={s.emptyButtonText}>Erneut versuchen</Text>
</TouchableOpacity>
</View>
</View>
);
}
const noResults = persons.length === 0 && unifiedShows.length === 0;
return (
<View style={s.container}>
<Stack.Screen
options={{
headerSearchBarOptions: {
placeholder: "Wonach suchst du?",
hideWhenScrolling: false,
autoCapitalize: "none",
onChangeText: (e) => {
const text = e.nativeEvent.text;
setQuery(text);
setShowSuggestions(text.length > 0);
},
onSearchButtonPress: (e) => {
const text = e.nativeEvent.text;
if (!text?.trim()) return;
tagAdded({ type: "CUSTOM", text: text.trim() });
},
onCancelButtonPress: () => {
setQuery("");
setShowSuggestions(false);
},
},
}}
/>
{/* Suggestions overlay */}
{query.length > 0 && showSuggestions && suggestions.length > 0 && (
<View style={s.suggestionsOverlay}>
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Text style={s.suggestionsHeader}>VORSCHLÄGE</Text>
<View style={s.suggestionsGroup}>
{suggestions.slice(0, 10).map((suggestion, idx, arr) => (
<TouchableOpacity
key={suggestion.text + "_" + idx}
style={[
s.suggestionRow,
idx === 0 && {
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
idx === arr.length - 1 && {
borderBottomLeftRadius: 10,
borderBottomRightRadius: 10,
borderBottomWidth: 0,
},
]}
onPress={() => tagAdded(suggestion)}
activeOpacity={0.6}
>
<Feather
name={
suggestion.type === "PERSON"
? "user"
: suggestion.type === "SHOW"
? "tv"
: "tag"
}
size={14}
color="rgba(255,255,255,0.5)"
/>
<Text style={s.suggestionText}>{suggestion.text}</Text>
<Feather
name="arrow-up-left"
size={12}
color="rgba(255,255,255,0.3)"
/>
</TouchableOpacity>
))}
</View>
</ScrollView>
</View>
)}
<ScrollView
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled"
>
{/* Tag chips */}
{tags.length > 0 && (
<View style={s.section}>
<View style={s.tagRow}>
{tags.map((tag) => (
<TagChip
key={tag.text}
icon={getIconName(tag.type)}
label={tag.text}
onPress={() => tagRemoved(tag)}
/>
))}
</View>
</View>
)}
{/* Personen Section */}
{persons.length > 0 && (
<View style={s.section}>
<Text style={s.sectionHeader}>PERSONEN</Text>
<View style={s.groupedCard}>
{persons.slice(0, 5).map((p, i, arr) => (
<PersonRow
key={`p-${p.personId ?? p.id}`}
person={p}
isFirst={i === 0}
isLast={i === arr.length - 1}
/>
))}
</View>
</View>
)}
{/* Shows + Staffeln unified */}
{unifiedShows.length > 0 && (
<View style={s.section}>
<Text style={s.sectionHeader}>SHOWS</Text>
{unifiedShows.map(({ show, seasons }) => (
<TouchableOpacity
key={`show-${show.id}`}
style={s.showCard}
activeOpacity={0.7}
onPress={() =>
router.push({
pathname: "/showDetails",
params: { id: String(show.id) },
})
}
>
<Image
source={{
uri: show.bannerUri || show.thumbnailUri,
}}
style={s.showCardImage}
resizeMode="cover"
/>
<View style={s.showCardBody}>
<View style={s.showCardHeader}>
<View style={{ flex: 1 }}>
<Text style={s.showCardTitle} numberOfLines={1}>
{show.title}
</Text>
{show.description ? (
<Text style={s.showCardDescription} numberOfLines={2}>
{show.description}
</Text>
) : null}
</View>
{show.running && (
<View style={s.liveBadge}>
<Text style={s.liveBadgeText}>LIVE</Text>
</View>
)}
</View>
{seasons.length > 0 && (
<View style={s.seasonRow}>
{seasons.map((season) => (
<View
key={(season as any).seasonId}
style={s.seasonPill}
>
<Text style={s.seasonPillText}>
S{(season as any).seasonNumber}
{(season as any).startDate
? ` · ${new Date((season as any).startDate).getFullYear()}`
: ""}
</Text>
</View>
))}
</View>
)}
</View>
</TouchableOpacity>
))}
</View>
)}
{/* Empty State */}
{noResults && (
<View style={s.section}>
<View style={s.emptyState}>
<Feather name="search" size={44} color="rgba(255,255,255,0.2)" />
<Text style={s.emptyTitle}>Keine Ergebnisse</Text>
<Text style={s.emptySubtitle}>
Passe deine Tags an oder setze die Filter zurück.
</Text>
{tags.length > 0 && (
<TouchableOpacity
onPress={() => {
setTags([]);
setQuery("");
setShowSuggestions(false);
if (typeof refetch === "function") refetch();
}}
style={s.emptyButton}
activeOpacity={0.7}
>
<Text style={s.emptyButtonText}>Filter zurücksetzen</Text>
</TouchableOpacity>
)}
</View>
</View>
)}
</ScrollView>
</View>
);
}
const s = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.background,
},
section: {
paddingHorizontal: 16,
marginBottom: 20,
paddingTop: 20,
},
sectionHeader: {
color: "rgba(255,255,255,0.45)",
fontSize: 13,
fontWeight: "600",
letterSpacing: 0.5,
marginBottom: 8,
paddingLeft: 4,
},
groupedCard: {
borderRadius: 10,
overflow: "hidden",
},
tagRow: {
flexDirection: "row",
flexWrap: "wrap",
},
// Suggestions overlay
suggestionsOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 10,
paddingHorizontal: 16,
paddingTop: 6,
backgroundColor: Colors.background,
},
suggestionsHeader: {
color: "rgba(255,255,255,0.45)",
fontSize: 12,
fontWeight: "600",
letterSpacing: 0.5,
marginBottom: 6,
paddingLeft: 4,
marginTop: Dimensions.get("screen").height / 7,
},
suggestionsGroup: {
borderRadius: 10,
overflow: "hidden",
},
suggestionRow: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(255,255,255,0.06)",
paddingHorizontal: 14,
paddingVertical: 10,
gap: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "rgba(255,255,255,0.08)",
},
suggestionText: {
color: "white",
fontSize: 15,
flex: 1,
},
// Empty state
emptyState: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 48,
paddingHorizontal: 32,
gap: 8,
},
emptyTitle: {
fontSize: 20,
fontWeight: "600",
color: "rgba(255,255,255,0.8)",
marginTop: 8,
},
emptySubtitle: {
fontSize: 15,
color: "rgba(255,255,255,0.45)",
textAlign: "center",
lineHeight: 20,
},
emptyButton: {
marginTop: 16,
backgroundColor: "rgba(255,255,255,0.1)",
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 10,
},
emptyButtonText: {
color: Colors.primary,
fontSize: 15,
fontWeight: "600",
},
// Unified show cards
showCard: {
borderRadius: 16,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.06)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255,255,255,0.08)",
marginBottom: 12,
},
showCardImage: {
width: "100%",
height: 160,
},
showCardBody: {
padding: 14,
gap: 10,
},
showCardHeader: {
flexDirection: "row",
alignItems: "flex-start",
gap: 10,
},
showCardTitle: {
color: "#fff",
fontSize: 17,
fontWeight: "700",
letterSpacing: 0.1,
},
showCardDescription: {
color: "rgba(255,255,255,0.45)",
fontSize: 13,
lineHeight: 18,
marginTop: 4,
},
liveBadge: {
backgroundColor: "rgba(255,59,48,0.2)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255,59,48,0.4)",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
},
liveBadgeText: {
color: "#ff3b30",
fontSize: 10,
fontWeight: "700",
letterSpacing: 0.5,
},
seasonRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: 6,
},
seasonPill: {
paddingVertical: 5,
paddingHorizontal: 12,
borderRadius: 14,
backgroundColor: "rgba(25,158,219,0.15)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(25,158,219,0.25)",
},
seasonPillText: {
color: "#199edb",
fontSize: 12,
fontWeight: "600",
},
});

View File

@@ -0,0 +1,30 @@
import { Colors } from "@/constants/colors";
import { Stack } from "expo-router";
export default function HomeLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: Colors.background },
headerTintColor: Colors.text,
headerTitleStyle: {
fontSize: 17,
fontWeight: "600",
},
}}
>
<Stack.Screen
name="index"
options={{
title: "FLTR",
headerLargeTitle: true,
headerLargeTitleStyle: {
color: Colors.text,
fontSize: 34,
fontWeight: "800",
},
}}
/>
</Stack>
);
}

353
app/(tabs)/home/index.tsx Normal file
View File

@@ -0,0 +1,353 @@
import ShowCard from "@/components/ui/ShowCard";
import { Colors } from "@/constants/colors";
import { useShows } from "@/hooks/useShows";
import { useStreamingServices } from "@/hooks/useStreamingServices";
import Feather from "@expo/vector-icons/Feather";
import * as Haptics from "expo-haptics";
import { router, Stack } from "expo-router";
import React from "react";
import {
ActivityIndicator,
Image,
Platform,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
export default function HomeScreen() {
const {
data: shows = [],
error,
isLoading: loading,
refetch: refetchShows,
} = useShows();
const { data: streamingServices = {}, refetch: refetchServices } =
useStreamingServices();
const [activeFilter, setActiveFilter] = React.useState<string>("all");
const [refreshing, setRefreshing] = React.useState(false);
const haptikFeedback = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const handleFilter = (type: string) => {
haptikFeedback();
setActiveFilter(type === activeFilter ? "all" : type);
};
const onRefresh = React.useCallback(async () => {
haptikFeedback();
setRefreshing(true);
try {
await Promise.all([
typeof refetchShows === "function" ? refetchShows() : Promise.resolve(),
typeof refetchServices === "function"
? refetchServices()
: Promise.resolve(),
]);
} finally {
setRefreshing(false);
}
}, [refetchShows, refetchServices]);
const filteredShows = React.useMemo(() => {
if (activeFilter === "all") return shows;
if (activeFilter === "live") return shows.filter((show) => show.running);
return shows.filter((show) =>
show.streamingService
.split(",")
.map((s) => s.trim())
.includes(activeFilter),
);
}, [shows, activeFilter]);
const uniqueStreamingServices = React.useMemo(() => {
const uniqueServices = new Set<string>();
shows.forEach((show) => {
show.streamingService
.split(", ")
.map((s) => s.trim())
.forEach((service) => uniqueServices.add(service));
});
return Array.from(uniqueServices);
}, [shows]);
if (loading) {
return (
<View style={s.centered}>
<ActivityIndicator size="large" color="rgba(255,255,255,0.6)" />
</View>
);
}
if (error) {
return (
<View style={s.centered}>
<View style={s.errorCard}>
<Text style={{ fontSize: 36 }}></Text>
<Text style={s.errorTitle}>Fehler beim Laden</Text>
<Text style={s.errorMessage}>
{error?.message || "Ein unerwarteter Fehler ist aufgetreten."}
</Text>
<TouchableOpacity
onPress={() => {
if (typeof refetchShows === "function") refetchShows();
if (typeof refetchServices === "function") refetchServices();
}}
style={s.retryButton}
>
<Text style={s.retryText}>Erneut versuchen</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<View style={s.container}>
<Stack.Screen
options={{
headerRight: () => (
<TouchableOpacity onPress={() => router.push("/legal")} hitSlop={8}>
<Feather
name="info"
size={22}
color={Platform.OS === "ios" ? Colors.primary : Colors.text}
style={{ left: "32.5%" }}
/>
</TouchableOpacity>
),
}}
/>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="rgba(255,255,255,0.6)"
/>
}
>
{/* Filter chips */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={s.filterRow}
>
<TouchableOpacity
style={[s.filterPill, activeFilter === "all" && s.filterPillActive]}
onPress={() => handleFilter("all")}
activeOpacity={0.7}
>
<Text
style={[
s.filterPillText,
activeFilter === "all" && s.filterPillTextActive,
]}
>
Alle
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
s.filterPill,
activeFilter === "live" && s.filterPillActive,
]}
onPress={() => handleFilter("live")}
activeOpacity={0.7}
>
<View style={s.liveDot} />
<Text
style={[
s.filterPillText,
activeFilter === "live" && s.filterPillTextActive,
]}
>
Live
</Text>
</TouchableOpacity>
{uniqueStreamingServices.map((serviceName) => {
const serviceUri =
streamingServices[
`assets.images.streamingServices.${serviceName.toLowerCase()}`
];
const isActive = activeFilter === serviceName;
return (
<TouchableOpacity
key={serviceName}
style={[s.serviceChip, isActive && s.serviceChipActive]}
onPress={() => handleFilter(serviceName)}
activeOpacity={0.7}
>
{serviceUri ? (
<Image source={{ uri: serviceUri }} style={s.serviceIcon} />
) : (
<Text style={s.filterPillText}>{serviceName}</Text>
)}
</TouchableOpacity>
);
})}
</ScrollView>
{/* Show cards */}
<View style={s.cardList}>
{filteredShows.map((show) => (
<ShowCard
key={show.id}
title={show.title}
onPress={() =>
router.push({
pathname: "/showDetails",
params: {
id: String(show.id),
title: show.title,
bannerUri: show.bannerUri,
description: show.description,
concept: show.concept,
genres: show.genres,
streamingService: show.streamingService,
logoUri: show.logoUrl,
running: String(show.running),
},
})
}
imageUri={show.bannerUri}
streamingServicesUris={show.streamingService
.split(", ")
.map(
(sv) =>
streamingServices[
`assets.images.streamingServices.${sv.toLowerCase()}`
],
)}
genres={show.genres}
{...(show.running
? {
liveBadgeText: "LIVE",
}
: {})}
/>
))}
</View>
</ScrollView>
</View>
);
}
const s = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.background,
},
centered: {
flex: 1,
backgroundColor: Colors.background,
justifyContent: "center",
alignItems: "center",
padding: 20,
},
errorCard: {
alignItems: "center",
gap: 10,
backgroundColor: "rgba(255,255,255,0.06)",
paddingVertical: 28,
paddingHorizontal: 24,
borderRadius: 16,
width: "90%",
},
errorTitle: {
fontSize: 17,
fontWeight: "600",
color: "white",
textAlign: "center",
},
errorMessage: {
fontSize: 14,
color: "rgba(255,255,255,0.55)",
textAlign: "center",
lineHeight: 20,
},
retryButton: {
marginTop: 8,
backgroundColor: "rgba(255,255,255,0.12)",
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 10,
},
retryText: {
color: "white",
fontWeight: "600",
fontSize: 15,
},
/* Filter row */
filterRow: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 4,
gap: 8,
alignItems: "center",
},
filterPill: {
flexDirection: "row",
alignItems: "center",
gap: 5,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: "rgba(255,255,255,0.08)",
},
filterPillActive: {
backgroundColor: "rgba(255,255,255,0.22)",
},
filterPillText: {
color: "rgba(255,255,255,0.7)",
fontSize: 14,
fontWeight: "600",
},
filterPillTextActive: {
color: "white",
},
liveDot: {
width: 7,
height: 7,
borderRadius: 4,
backgroundColor: "#ff3b30",
},
serviceChip: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "rgba(255,255,255,0.08)",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
},
serviceChipActive: {
borderWidth: 2,
borderColor: Colors.primary,
},
serviceIcon: {
width: 40,
height: 40,
borderRadius: 20,
resizeMode: "contain",
},
/* Card list */
cardList: {
paddingHorizontal: 16,
paddingBottom: 30,
},
});

View File

@@ -1,353 +1,5 @@
import styles from "@/app/tabStyles/indexStyles"; import { Redirect } from "expo-router";
import ShowCard from "@/components/ui/ShowCard";
import { useShows } from "@/hooks/useShows";
import { useStreamingServices } from "@/hooks/useStreamingServices";
import Feather from "@expo/vector-icons/Feather";
import * as Haptics from "expo-haptics";
import { router } from "expo-router";
import React from "react";
import {
ActivityIndicator,
Image,
RefreshControl,
ScrollView as RNScrollView,
Text,
TouchableOpacity,
View,
} from "react-native";
import {
GestureHandlerRootView,
ScrollView, // horizontaler ScrollView bleibt aus RNGH
} from "react-native-gesture-handler";
export default function HomeScreen() { export default function () {
const { return <Redirect href="/home" />;
data: shows = [],
error,
isLoading: loading,
refetch: refetchShows, // ⬅️ refetch aus Hook
} = useShows();
const {
data: streamingServices = {},
refetch: refetchServices, // ⬅️ refetch aus Hook
} = useStreamingServices();
const [activeFilter, setActiveFilter] = React.useState<string>("all");
const [refreshing, setRefreshing] = React.useState(false); // ⬅️ UI-State für Pull-to-Refresh
const haptikFeedback = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const handleFilter = (type: string) => {
haptikFeedback();
if (type === activeFilter) {
setActiveFilter("all");
} else {
setActiveFilter(type);
}
};
// ⬅️ Pull-to-Refresh Handler
const onRefresh = React.useCallback(async () => {
haptikFeedback();
setRefreshing(true);
try {
await Promise.all([
typeof refetchShows === "function" ? refetchShows() : Promise.resolve(),
typeof refetchServices === "function" ? refetchServices() : Promise.resolve(),
]);
} finally {
setRefreshing(false);
}
}, [refetchShows, refetchServices]);
const filteredShows = React.useMemo(() => {
if (activeFilter === "all") {
return shows;
}
if (activeFilter === "live") {
return shows.filter((show) => show.running);
}
return shows.filter((show) =>
show.streamingService
.split(",")
.map((s) => s.trim())
.includes(activeFilter)
);
}, [shows, activeFilter]);
const uniqueStreamingServices = React.useMemo(() => {
const uniqueServices = new Set<string>();
shows.forEach((show) => {
const services = show.streamingService.split(", ").map((s) => s.trim());
services.forEach((service) => uniqueServices.add(service));
});
return Array.from(uniqueServices);
}, [shows]);
if (loading) {
return (
<View
style={[
styles.mainContainer,
{ justifyContent: "center", alignItems: "center" },
]}
>
<ActivityIndicator size="large" color="#ffffff" />
</View>
);
}
if (error) {
return (
<View
style={[
styles.mainContainer,
{ justifyContent: "center", alignItems: "center", padding: 20 },
]}
>
<View
style={{
alignItems: "center",
gap: 12,
backgroundColor: "rgba(255,255,255,0.05)",
paddingVertical: 24,
paddingHorizontal: 20,
borderRadius: 12,
width: "85%",
}}
>
<Text style={{ fontSize: 36 }}></Text>
<Text
style={{
fontSize: 18,
fontWeight: "600",
color: "white",
textAlign: "center",
}}
>
Fehler beim Laden
</Text>
<Text
style={{
fontSize: 14,
color: "rgba(255,255,255,0.6)",
textAlign: "center",
}}
>
{error?.message || "Ein unerwarteter Fehler ist aufgetreten."}
</Text>
<TouchableOpacity
onPress={() => {
if (typeof refetchShows === "function") refetchShows();
if (typeof refetchServices === "function") refetchServices();
}}
style={{
marginTop: 6,
backgroundColor: "rgba(255,255,255,0.15)",
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 8,
}}
>
<Text style={{ color: "white", fontWeight: "600" }}>Erneut versuchen</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<GestureHandlerRootView>
<View style={styles.mainContainer}>
<View style={styles.header}>
<TouchableOpacity
onPress={() => {
router.push("/legal");
}}
style={{
position: "absolute",
left: 16,
top: "63%",
transform: [{ translateY: -12 }],
height: 40,
width: 40,
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "rgba(255,255,255,0.06)",
}}
accessibilityRole="button"
accessibilityLabel="Menü öffnen"
>
<Feather name="menu" size={22} color="#FFFFFF" />
</TouchableOpacity>
<Text style={styles.title}>FLTR</Text>
</View>
<RNScrollView
contentContainerStyle={{ paddingBottom: 30 }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#FFFFFF" // iOS Spinner
colors={["#FFFFFF"]} // Android Spinner
progressBackgroundColor="hsla(0, 0%, 29%, 1.00)"
/>
}
>
<View style={styles.filterSection}>
{/* ⬅️ HORIZONTALER SCROLLBEREICH BLEIBT AUS RNGH */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
alignItems: "center",
paddingHorizontal: 10,
gap: 10,
marginLeft: 10,
}}
>
{activeFilter !== "all" && (
<TouchableOpacity
style={{
padding: 5,
height: 60,
width: 60,
alignItems: "center",
justifyContent: "center",
backgroundColor: "hsla(0, 0%, 29%, 1.00)",
borderRadius: 50,
}}
onPress={() => handleFilter("all")}
>
<Text style={{ fontWeight: "bold", color: "white" }}>
ALLE
</Text>
</TouchableOpacity>
)}
{activeFilter !== "live" && (
<TouchableOpacity
style={{
padding: 5,
height: 60,
width: 60,
alignItems: "center",
justifyContent: "center",
backgroundColor: "hsla(0, 0%, 29%, 1.00)",
borderRadius: 50,
}}
onPress={() => handleFilter("live")}
>
<View
style={{
backgroundColor: "red",
paddingHorizontal: 5,
paddingVertical: 2,
borderRadius: 5,
}}
>
<Text style={{ fontWeight: "bold", color: "white" }}>
LIVE
</Text>
</View>
</TouchableOpacity>
)}
<View
style={{
height: 60,
width: 2,
backgroundColor: "hsla(0, 0%, 37%, 1.00)",
marginHorizontal: 5,
borderRadius: 5,
}}
/>
{uniqueStreamingServices.map((serviceName) => {
const streamingService =
streamingServices[
`assets.images.streamingServices.${serviceName.toLowerCase()}`
];
return (
<TouchableOpacity
key={serviceName}
style={{
padding: 5,
backgroundColor: "hsla(0, 0%, 29%, 1.00)",
borderRadius: 50,
borderWidth: serviceName.includes(activeFilter) ? 2 : 0,
borderColor: "hsla(0, 100%, 50%, 1.00)",
}}
onPress={() => handleFilter(serviceName)}
>
<Image
source={{ uri: streamingService }}
style={{
width: 50,
height: 50,
borderRadius: 30,
resizeMode: "contain",
}}
/>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
<View style={{ flex: 1, paddingHorizontal: 10 }}>
{filteredShows.map((show) => {
const showLiveBadge = show.running;
return (
<ShowCard
key={show.id}
title={show.title}
onPress={() =>
router.push({
pathname: "/showDetails",
params: {
id: String(show.id),
title: show.title,
bannerUri: show.bannerUri,
description: show.description,
concept: show.concept,
genres: show.genres,
streamingService: show.streamingService,
logoUri: show.logoUrl,
running: String(show.running),
},
})
}
imageUri={show.bannerUri}
streamingServicesUris={show.streamingService
.split(", ")
.map(
(s) =>
streamingServices[
`assets.images.streamingServices.${s.toLowerCase()}`
]
)}
genres={show.genres}
{...(showLiveBadge
? {
liveBadgeText: "LIVE",
liveBadgeContainerStyle: styles.liveBadgeContainer,
}
: {})}
/>
);
})}
</View>
</RNScrollView>
</View>
</GestureHandlerRootView>
);
} }

View File

@@ -1,3 +1,4 @@
import { Colors } from "@/constants/colors";
import { DiscoveryProvider } from "@/contexts/DiscoveryContext"; import { DiscoveryProvider } from "@/contexts/DiscoveryContext";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
@@ -9,28 +10,46 @@ export default function RootLayout() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<DiscoveryProvider> <DiscoveryProvider>
<Stack> <Stack
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> screenOptions={{
headerStyle: { backgroundColor: Colors.background },
headerTintColor: "#199edb",
headerTitleStyle: { color: "white" },
headerBackTitle: "",
}}
>
<Stack.Screen
name="(tabs)"
options={{ headerShown: false, headerBackTitle: "" }}
/>
<Stack.Screen <Stack.Screen
name="showDetails" name="showDetails"
options={{ options={{
headerShown: false, headerShown: true,
headerTransparent: true,
headerBlurEffect: "dark",
title: "",
headerBackButtonDisplayMode: "minimal",
}} }}
/> />
<Stack.Screen <Stack.Screen
name="participant" name="participant"
options={{ options={{
headerShown: false, headerShown: true,
headerTransparent: true,
headerBlurEffect: "dark",
title: "",
headerBackButtonDisplayMode: "minimal",
}} }}
/> />
<Stack.Screen <Stack.Screen
name="legal" name="legal"
options={{ options={{
presentation: "modal", presentation: "modal",
headerShown: false headerShown: false,
}} /> }}
/>
</Stack> </Stack>
</DiscoveryProvider> </DiscoveryProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -1,154 +1,227 @@
import { Colors } from "@/constants/colors";
import Feather from "@expo/vector-icons/Feather"; import Feather from "@expo/vector-icons/Feather";
import { BlurView } from "expo-blur";
import { router } from "expo-router"; import { router } from "expo-router";
import React from "react"; import React from "react";
import { ScrollView, Text, TouchableOpacity, View } from "react-native"; import {
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
export default function LegalScreen() { export default function LegalScreen() {
return ( return (
<View style={{ flex: 1, backgroundColor: "hsl(220, 15%, 10%)" }}> <View style={styles.container}>
{/* Header */}
<BlurView intensity={40} tint="dark" style={styles.header}>
<View <Text style={styles.headerTitle}>Info</Text>
style={{
paddingTop: 18,
paddingHorizontal: 16,
paddingBottom: 6,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text
style={{
color: "white",
fontSize: 20,
fontWeight: "700",
letterSpacing: 0.3,
}}
>
Info
</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => router.back()} onPress={() => router.back()}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Modal schließen" accessibilityLabel="Modal schließen"
style={{ style={styles.closeButton}
height: 40,
width: 40,
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "rgba(255,255,255,0.08)",
}}
> >
<Feather name="x" size={22} color="#FFFFFF" /> <Feather name="x" size={18} color="rgba(255,255,255,0.85)" />
</TouchableOpacity> </TouchableOpacity>
</View> </BlurView>
<ScrollView <ScrollView
contentContainerStyle={{ contentContainerStyle={styles.scrollContent}
paddingHorizontal: 16,
paddingBottom: 28,
}}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Impressum Card */} {/* Impressum */}
<View <View style={styles.card}>
style={{ <View style={styles.cardHeader}>
backgroundColor: "rgba(255,255,255,0.05)", <View style={styles.cardIconCircle}>
borderRadius: 14, <Feather name="briefcase" size={16} color="#199edb" />
padding: 16, </View>
gap: 10, <Text style={styles.cardTitle}>Impressum</Text>
marginTop: 8,
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "700" }}>
Impressum
</Text>
<View style={{ gap: 4 }}>
<Text style={styles.mono}>Berg Autosoft</Text>
<Text style={styles.mono}>Joe Felipe Berg</Text>
<Text style={styles.mono}>Stöckener Straße 35</Text>
<Text style={styles.mono}>30419 Hannover</Text>
</View> </View>
<View style={{ height: 8 }} /> <View style={styles.cardSection}>
<Text style={styles.textPrimary}>Berg Autosoft</Text>
<View style={{ gap: 4 }}> <Text style={styles.textPrimary}>Joe Felipe Berg</Text>
<Text style={styles.dim}>+49 1522 5642948</Text> <Text style={styles.textPrimary}>Stöckener Straße 35</Text>
<Text style={styles.dim}>kontakt@berg-autosoft.de</Text> <Text style={styles.textPrimary}>30419 Hannover</Text>
</View> </View>
<View style={{ height: 8 }} /> <View style={styles.divider} />
<View style={{ gap: 4 }}> <View style={styles.cardSection}>
<Text style={styles.dim}>Steuernummer: 25/103/17193</Text> <View style={styles.infoRow}>
<Text style={styles.dim}>USt-ID: DE361689728</Text> <Feather name="phone" size={14} color="rgba(255,255,255,0.4)" />
<Text style={styles.textSecondary}>+49 1522 5642948</Text>
</View>
<View style={styles.infoRow}>
<Feather name="mail" size={14} color="rgba(255,255,255,0.4)" />
<Text style={styles.textSecondary}>kontakt@berg-autosoft.de</Text>
</View> </View>
</View> </View>
{/* Support Card */} <View style={styles.divider} />
<View
style={{
backgroundColor: "rgba(255,255,255,0.05)",
borderRadius: 14,
padding: 16,
gap: 10,
marginTop: 12,
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "700" }}>
Support
</Text>
<Text style={styles.body}> <View style={styles.cardSection}>
<Text style={styles.textDim}>Steuernummer: 25/103/17193</Text>
<Text style={styles.textDim}>USt-ID: DE361689728</Text>
</View>
</View>
{/* Support */}
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.cardIconCircle}>
<Feather name="headphones" size={16} color="#199edb" />
</View>
<Text style={styles.cardTitle}>Support</Text>
</View>
<Text style={styles.textBody}>
Sollten Sie Probleme bei der Nutzung der iOS- oder Android-App FLTR Sollten Sie Probleme bei der Nutzung der iOS- oder Android-App FLTR
haben, wenden Sie sich bitte direkt an den Support. haben, wenden Sie sich bitte direkt an den Support.
</Text> </Text>
<View style={{ height: 6 }} /> <View style={styles.divider} />
<Text style={styles.body}>Schreiben Sie eine E-Mail an:</Text> <Text style={styles.textDim}>Schreiben Sie eine E-Mail an:</Text>
<Text style={[styles.mono, { fontSize: 15 }]}> <View style={styles.emailPill}>
developer@berg-autosoft.de <Feather name="mail" size={14} color="#199edb" />
</Text> <Text style={styles.emailText}>developer@berg-autosoft.de</Text>
</View>
<Text style={[styles.dim, { marginTop: 10 }]}> <Text style={[styles.textDim, { marginTop: 12 }]}>
Wir bemühen uns, Ihr Anliegen so schnell wie möglich zu bearbeiten. Wir bemühen uns, Ihr Anliegen so schnell wie möglich zu bearbeiten.
</Text> </Text>
</View> </View>
{/* Footer */} {/* Footer */}
<View style={{ alignItems: "center", marginTop: 18 }}> <Text style={styles.footer}>© 2025 Berg Autosoft</Text>
<Text
style={{
color: "rgba(255,255,255,0.6)",
fontSize: 12,
letterSpacing: 0.2,
}}
>
© 2025 Berg Autosoft
</Text>
</View>
</ScrollView> </ScrollView>
</View> </View>
); );
} }
const styles = { const styles = StyleSheet.create({
mono: { container: {
flex: 1,
backgroundColor: Colors.background,
},
header: {
paddingTop: 18,
paddingHorizontal: 16,
paddingBottom: 14,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "rgba(255,255,255,0.08)",
overflow: "hidden",
},
headerTitle: {
color: "white",
fontSize: 20,
fontWeight: "700",
letterSpacing: 0.3,
},
closeButton: {
height: 32,
width: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: 16,
backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255,255,255,0.12)",
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
paddingTop: 12,
},
card: {
backgroundColor: "rgba(255,255,255,0.06)",
borderRadius: 18,
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255,255,255,0.08)",
padding: 18,
gap: 12,
marginTop: 10,
},
cardHeader: {
flexDirection: "row",
alignItems: "center",
gap: 10,
marginBottom: 2,
},
cardIconCircle: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "rgba(25,158,219,0.15)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(25,158,219,0.25)",
justifyContent: "center",
alignItems: "center",
},
cardTitle: {
color: "white",
fontSize: 18,
fontWeight: "700",
},
cardSection: {
gap: 4,
},
divider: {
height: StyleSheet.hairlineWidth,
backgroundColor: "rgba(255,255,255,0.08)",
},
infoRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingVertical: 2,
},
textPrimary: {
color: "rgba(255,255,255,0.92)", color: "rgba(255,255,255,0.92)",
fontSize: 16, fontSize: 15,
} as const, },
dim: { textSecondary: {
color: "rgba(255,255,255,0.75)", color: "rgba(255,255,255,0.75)",
fontSize: 14, fontSize: 14,
} as const, },
body: { textDim: {
color: "rgba(255,255,255,0.88)", color: "rgba(255,255,255,0.5)",
fontSize: 13,
},
textBody: {
color: "rgba(255,255,255,0.8)",
fontSize: 14, fontSize: 14,
lineHeight: 20, lineHeight: 20,
} as const, },
}; emailPill: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 20,
backgroundColor: "rgba(25,158,219,0.12)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(25,158,219,0.25)",
alignSelf: "flex-start",
marginTop: 4,
},
emailText: {
color: "#199edb",
fontSize: 14,
fontWeight: "600",
},
footer: {
color: "rgba(255,255,255,0.35)",
fontSize: 12,
letterSpacing: 0.2,
textAlign: "center",
marginTop: 24,
},
});

View File

@@ -1,23 +1,32 @@
import { PersonMini } from "@/apis/personHistoryApi"; import { PersonMini } from "@/apis/personHistoryApi";
import styles from "@/app/stackStyles/participantStyles"; import styles from "@/app/stackStyles/participantStyles";
import { usePersonHistory, AppearanceGroup } from "@/hooks/usePersonHistory"; import { usePersonHistory } from "@/hooks/usePersonHistory";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import * as WebBrowser from "expo-web-browser"; import * as WebBrowser from "expo-web-browser";
import React from "react"; import React from "react";
import { Image, Text, TouchableOpacity, View } from "react-native"; import {
ActivityIndicator,
Image,
Text,
TouchableOpacity,
View,
} from "react-native";
import { import {
GestureHandlerRootView, GestureHandlerRootView,
ScrollView, ScrollView,
} from "react-native-gesture-handler"; } from "react-native-gesture-handler";
export default function ParticipantScreen() { export default function ParticipantScreen() {
const { name, participantId } = useLocalSearchParams(); const { name, participantId, imageUri } = useLocalSearchParams();
const pid = Array.isArray(participantId) const pid = Array.isArray(participantId)
? Number(participantId[0]) ? Number(participantId[0])
: Number(participantId); : Number(participantId);
const imageUriString = Array.isArray(imageUri) ? imageUri[0] : imageUri;
const isPravatar = imageUriString?.includes("pravatar");
const { data: appearances = [], isLoading, isError } = usePersonHistory(pid); const { data: appearances = [], isLoading, isError } = usePersonHistory(pid);
const formatYear = (iso?: string | null) => { const formatYear = (iso?: string | null) => {
@@ -27,7 +36,7 @@ export default function ParticipantScreen() {
}; };
const [expandedShows, setExpandedShows] = React.useState<Set<number>>( const [expandedShows, setExpandedShows] = React.useState<Set<number>>(
new Set() new Set(),
); );
const toggleExpand = React.useCallback((showId: number) => { const toggleExpand = React.useCallback((showId: number) => {
setExpandedShows((prev) => { setExpandedShows((prev) => {
@@ -45,45 +54,79 @@ export default function ParticipantScreen() {
const goToPerson = React.useCallback( const goToPerson = React.useCallback(
(p: PersonMini) => { (p: PersonMini) => {
if (!p?.personId) return; if (!p?.personId) return;
if (p.personId === pid) return; if (p.personId === pid) return;
router.push({ router.push({
pathname: "/participant", pathname: "/participant",
params: { participantId: String(p.personId), name: p.name }, params: {
participantId: String(p.personId),
name: p.name,
imageUri: p.imageUrl || "",
},
}); });
}, },
[pid] [pid],
); );
return ( return (
<GestureHandlerRootView style={styles.mainContainer}> <GestureHandlerRootView style={styles.mainContainer}>
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20, paddingTop: 10}} contentContainerStyle={{ paddingBottom: 40, paddingTop: 100 }}
> >
{/* Profile Hero */}
<View style={styles.profileHero}>
<View style={styles.profileImageContainer}>
<Image
source={{ uri: imageUriString || undefined }}
style={styles.profileImage}
resizeMode="cover"
blurRadius={isPravatar ? 16 : 0}
/>
</View>
<Text style={styles.participantName}>{name}</Text> <Text style={styles.participantName}>{name}</Text>
<TouchableOpacity <Text style={styles.participantSubtitle}>
style={styles.closeIcon} {appearances.length}{" "}
onPress={() => router.back()} {appearances.length === 1 ? "Auftritt" : "Auftritte"}
> </Text>
<Ionicons name="close-circle-outline" size={38} color="white" /> <View style={styles.heroButtons}>
</TouchableOpacity>
<View style={styles.performedShowsSection}>
<TouchableOpacity <TouchableOpacity
style={styles.searchButton} style={styles.searchButton}
onPress={() => onPress={() =>
WebBrowser.openBrowserAsync( WebBrowser.openBrowserAsync(
"https://www.google.com/search?udm=2&q=" + "https://www.google.com/search?udm=2&q=" +
encodeURIComponent(String(name)) encodeURIComponent(String(name)),
) )
} }
> >
<Ionicons name="images-outline" size={24} color="white" /> <Ionicons name="images-outline" size={18} color="#199edb" />
<Text style={styles.searchButtonText}>Bilder</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
style={styles.instagramButton}
onPress={() =>
WebBrowser.openBrowserAsync(
"https://www.google.com/search?q=" +
encodeURIComponent(`${String(name)} Instagram`),
)
}
>
<Ionicons name="logo-instagram" size={18} color="#E1306C" />
<Text style={styles.instagramButtonText}>Instagram</Text>
</TouchableOpacity>
</View>
</View>
<Text style={styles.performedShowsTitle}>Auftritte:</Text> {/* Loading */}
{isLoading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#199edb" />
</View>
)}
{/* Appearances */}
{!isLoading && appearances.length > 0 && (
<View style={styles.performedShowsSection}>
<Text style={styles.performedShowsTitle}>Auftritte</Text>
<ScrollView <ScrollView
horizontal horizontal
@@ -91,8 +134,8 @@ export default function ParticipantScreen() {
style={{ flex: 1 }} style={{ flex: 1 }}
contentContainerStyle={{ contentContainerStyle={{
gap: 20, gap: 20,
paddingHorizontal: 15, paddingHorizontal: 16,
paddingLeft: 30, paddingTop: 12,
}} }}
> >
{appearances.toReversed().map(({ show, seasons }) => { {appearances.toReversed().map(({ show, seasons }) => {
@@ -101,8 +144,8 @@ export default function ParticipantScreen() {
seasons seasons
.map((s) => s.partner) .map((s) => s.partner)
.filter((p): p is NonNullable<typeof p> => !!p) .filter((p): p is NonNullable<typeof p> => !!p)
.map((p) => [p.personId, p]) .map((p) => [p.personId, p]),
).values() ).values(),
); );
const allParticipants = Array.from( const allParticipants = Array.from(
@@ -110,8 +153,8 @@ export default function ParticipantScreen() {
seasons seasons
.flatMap((s) => s.participants) .flatMap((s) => s.participants)
.filter((p) => p.personId !== pid) .filter((p) => p.personId !== pid)
.map((p) => [p.personId, p]) .map((p) => [p.personId, p]),
).values() ).values(),
); );
const isExpanded = expandedShows.has(show.id); const isExpanded = expandedShows.has(show.id);
@@ -120,7 +163,7 @@ export default function ParticipantScreen() {
: allParticipants.slice(0, 12); : allParticipants.slice(0, 12);
const restCount = Math.max( const restCount = Math.max(
allParticipants.length - visible.length, allParticipants.length - visible.length,
0 0,
); );
return ( return (
@@ -135,23 +178,71 @@ export default function ParticipantScreen() {
resizeMode="cover" resizeMode="cover"
/> />
</TouchableOpacity> </TouchableOpacity>
<View style={styles.cardInfo}>
<View style={styles.cardTitleRow}>
<View style={{ flex: 1 }}>
<Text style={styles.showTitle} numberOfLines={1}> <Text style={styles.showTitle} numberOfLines={1}>
{show.title} {show.title}
</Text> </Text>
<Text style={styles.showSeason}> <Text style={styles.showSeason}>
({formatYear(seasons[0]?.startDate)}) Staffel{" "}
{seasons.map((s) => s.seasonNumber).join(" & ")}
{" · "}
{formatYear(seasons[0]?.startDate)}
</Text> </Text>
<Text style={styles.showSeason}> </View>
Staffel {seasons.map((s) => s.seasonNumber).join(" und ")} <TouchableOpacity
style={styles.cardSearchButton}
onPress={() =>
WebBrowser.openBrowserAsync(
"https://www.google.com/search?udm=2&q=" +
encodeURIComponent(
`${String(name)} ${show.title}`,
),
)
}
>
<Ionicons
name="images-outline"
size={16}
color="#199edb"
/>
</TouchableOpacity>
</View>
{partners.length > 0 && (
<View style={styles.partnerSection}>
<Text style={styles.sectionLabel}>Partner</Text>
<View style={styles.partnerRow}>
{partners.map((p) => (
<TouchableOpacity
key={p.personId}
style={styles.partnerChip}
onPress={() => goToPerson(p)}
>
<Ionicons
name="heart"
size={12}
color="#e74c8b"
/>
<Text
style={styles.partnerChipText}
numberOfLines={1}
>
{p.name}
</Text> </Text>
</TouchableOpacity>
))}
</View>
</View>
)}
<View style={styles.horizontalLine} /> {allParticipants.length > 0 && (
<View style={styles.participantsSection}>
<Text style={[styles.participantLabel, { marginTop: 10 }]}> <Text style={styles.sectionLabel}>
Weitere Teilnehmer Weitere Teilnehmer
</Text> </Text>
<View style={styles.participantContainer}>
<View style={styles.participantRow}> <View style={styles.participantRow}>
{visible.map((p) => ( {visible.map((p) => (
<TouchableOpacity <TouchableOpacity
@@ -189,49 +280,14 @@ export default function ParticipantScreen() {
)} )}
</View> </View>
</View> </View>
{partners.length > 0 && (
<>
<View style={styles.horizontalLine} />
<Text
style={[styles.participantLabel, { marginTop: 10 }]}
>
Partner
</Text>
<View
style={[
styles.showContainer,
{
backgroundColor: "hsl(221, 39%, 12%)",
width: 150,
marginTop: 20,
},
]}
>
<Image
style={styles.showImage}
blurRadius={20}
source={{
uri: `https://i.pravatar.cc/300?img=${Math.floor(Math.random() * 70)}`,
}}
/>
</View>
{partners.map((p) => (
<Text
key={p.personId}
style={styles.partnerLabel}
numberOfLines={1}
>
{p.name}
</Text>
))}
</>
)} )}
</View> </View>
</View>
); );
})} })}
</ScrollView> </ScrollView>
</View> </View>
)}
</ScrollView> </ScrollView>
</GestureHandlerRootView> </GestureHandlerRootView>
); );

View File

@@ -1,6 +1,5 @@
import ParticipantDetails from "@/components/ui/ParticipantDeatails"; import ParticipantDetails from "@/components/ui/ParticipantDeatails";
import ShowInfo from "@/components/ui/ShowInfo"; import ShowInfo from "@/components/ui/ShowInfo";
import StackHeader from "@/components/ui/StackHeader";
import { import {
useSeasonCount, useSeasonCount,
useSeasonDates, useSeasonDates,
@@ -11,9 +10,11 @@ import * as Haptics from "expo-haptics";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import React from "react"; import React from "react";
import { import {
ActivityIndicator,
Dimensions, Dimensions,
Image, Image,
ScrollView, ScrollView,
StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
View, View,
@@ -21,15 +22,17 @@ import {
import styles from "./stackStyles/showDetailStyles"; import styles from "./stackStyles/showDetailStyles";
export default function ShowDetails() { export default function ShowDetails() {
const { id } = useLocalSearchParams(); const { id, logoUri } = useLocalSearchParams();
const showId = Number(id); const showId = Number(id);
const logoUriString = Array.isArray(logoUri) ? logoUri[0] : logoUri;
const [selectedParticipants, setSelectedParticipants] = const [selectedParticipants, setSelectedParticipants] =
React.useState<boolean>(true); React.useState<boolean>(true);
const [selectedSeason, setSelectedSeason] = React.useState<number>(1); const [selectedSeason, setSelectedSeason] = React.useState<number>(1);
const { data: show } = useShow(showId); const { data: show, isLoading: showLoading } = useShow(showId);
const { data: seasonCount = 0 } = useSeasonCount(showId); const { data: seasonCount = 0, isLoading: seasonCountLoading } =
useSeasonCount(showId);
const { const {
data: participants, data: participants,
isLoading: pLoading, isLoading: pLoading,
@@ -40,7 +43,7 @@ export default function ShowDetails() {
const sortedParticipants = React.useMemo(() => { const sortedParticipants = React.useMemo(() => {
return [...participants].sort((a, b) => return [...participants].sort((a, b) =>
a.name.localeCompare(b.name, "de", { sensitivity: "base" }) a.name.localeCompare(b.name, "de", { sensitivity: "base" }),
); );
}, [participants]); }, [participants]);
@@ -62,29 +65,45 @@ export default function ShowDetails() {
}, [startDate]); }, [startDate]);
const handleOpenParticipant = React.useCallback( const handleOpenParticipant = React.useCallback(
(p: { id: number; name: string }) => { (p: { id: number; name: string; imageUri?: string }) => {
router.push({ router.push({
pathname: "/participant", pathname: "/participant",
params: { params: {
participantId: p.id, participantId: p.id,
name: p.name, name: p.name,
imageUri: p.imageUri || "",
originShowId: String(showId), originShowId: String(showId),
originSeason: String(selectedSeason), originSeason: String(selectedSeason),
}, },
}); });
}, },
[showId, selectedSeason] [showId, selectedSeason],
); );
const isInitialLoading = showLoading || seasonCountLoading;
return ( return (
<View style={styles.mainContainer}> <View style={styles.mainContainer}>
<StackHeader /> {isInitialLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#199edb" />
</View>
) : (
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingBottom: Dimensions.get("window").height * 0.1, paddingBottom: Dimensions.get("window").height * 0.1,
}} }}
> >
{logoUriString ? (
<View style={styles.logoContainer}>
<Image
source={{ uri: logoUriString }}
style={styles.showLogo}
resizeMode="contain"
/>
</View>
) : null}
{formattedStartDate ? ( {formattedStartDate ? (
<Text style={styles.startDate}>{formattedStartDate}</Text> <Text style={styles.startDate}>{formattedStartDate}</Text>
) : null} ) : null}
@@ -106,26 +125,54 @@ export default function ShowDetails() {
/> />
</View> </View>
<View style={styles.infoContainner}> <View style={styles.infoContainner}>
<TouchableOpacity onPress={() => setSelectedParticipants(true)}> <TouchableOpacity
onPress={() => setSelectedParticipants(true)}
style={{
backgroundColor: selectedParticipants
? "rgba(25,158,219,0.2)"
: "transparent",
borderRadius: 20,
borderWidth: selectedParticipants
? StyleSheet.hairlineWidth
: 0,
borderColor: "rgba(25,158,219,0.4)",
}}
>
<Text <Text
style={[ style={[
styles.infoLabel, styles.infoLabel,
{ {
fontWeight: selectedParticipants ? "bold" : "normal", fontWeight: selectedParticipants ? "700" : "500",
color: selectedParticipants ? "#199edb" : "hsl(0, 0%, 65%)", color: selectedParticipants
? "#199edb"
: "rgba(255,255,255,0.45)",
}, },
]} ]}
> >
Teilnehmer Teilnehmer
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => setSelectedParticipants(false)}> <TouchableOpacity
onPress={() => setSelectedParticipants(false)}
style={{
backgroundColor: !selectedParticipants
? "rgba(25,158,219,0.2)"
: "transparent",
borderRadius: 20,
borderWidth: !selectedParticipants
? StyleSheet.hairlineWidth
: 0,
borderColor: "rgba(25,158,219,0.4)",
}}
>
<Text <Text
style={[ style={[
styles.infoLabel, styles.infoLabel,
{ {
fontWeight: !selectedParticipants ? "bold" : "normal", fontWeight: !selectedParticipants ? "700" : "500",
color: !selectedParticipants ? "#199edb" : "hsl(0, 0%, 65%)", color: !selectedParticipants
? "#199edb"
: "rgba(255,255,255,0.45)",
}, },
]} ]}
> >
@@ -152,17 +199,23 @@ export default function ShowDetails() {
backgroundColor: backgroundColor:
selectedSeason === season selectedSeason === season
? "#199edb" ? "#199edb"
: "hsl(0, 0%, 20%)", : "rgba(255,255,255,0.08)",
borderColor:
selectedSeason === season
? "rgba(25,158,219,0.3)"
: "rgba(255,255,255,0.06)",
}, },
]} ]}
onPress={() => { onPress={() => {
setSelectedSeason(season); setSelectedSeason(season);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(
Haptics.ImpactFeedbackStyle.Light,
);
}} }}
> >
<Text style={styles.seasonLabel}>{season}</Text> <Text style={styles.seasonLabel}>{season}</Text>
</TouchableOpacity> </TouchableOpacity>
) ),
)} )}
</ScrollView> </ScrollView>
</View> </View>
@@ -174,28 +227,48 @@ export default function ShowDetails() {
]} ]}
> >
{pError && ( {pError && (
<Text style={{ color: "tomato", marginBottom: 8 }}> <Text
style={{ color: "tomato", marginBottom: 8, fontSize: 13 }}
>
{pError} {pError}
</Text> </Text>
)} )}
{!pLoading && !pError && participants.length === 0 && ( {pLoading && (
<Text style={{ color: "gray" }}>Keine Teilnehmer.</Text> <View style={styles.sectionLoading}>
<ActivityIndicator size="small" color="#199edb" />
</View>
)} )}
{sortedParticipants.map((p) => ( {!pLoading && !pError && participants.length === 0 && (
<Text
style={{ color: "rgba(255,255,255,0.4)", fontSize: 14 }}
>
Keine Teilnehmer.
</Text>
)}
{!pLoading &&
sortedParticipants.map((p) => (
<TouchableOpacity <TouchableOpacity
key={p.id} key={p.id}
style={styles.participantWrapper}
onPress={() => handleOpenParticipant(p)}
>
<View
style={[ style={[
styles.participantContainer, styles.participantContainer,
{ backgroundColor: "hsl(336, 79%, 63%)" }, { backgroundColor: "hsl(336, 79%, 63%)" },
]} ]}
onPress={() => handleOpenParticipant(p)}
> >
<Image <Image
source={{ uri: p.imageUri }} source={{ uri: p.imageUri }}
style={{ width: "100%", height: "100%", borderRadius: 10 }} style={{
width: "100%",
height: "100%",
borderRadius: 16,
}}
resizeMode="cover" resizeMode="cover"
blurRadius={p.imageUri.includes("pravatar") ? 16 : 0} blurRadius={p.imageUri.includes("pravatar") ? 16 : 0}
/> />
</View>
<Text style={styles.participantLabel} numberOfLines={2}> <Text style={styles.participantLabel} numberOfLines={2}>
{p.name} {p.name}
</Text> </Text>
@@ -212,6 +285,7 @@ export default function ShowDetails() {
/> />
)} )}
</ScrollView> </ScrollView>
)}
</View> </View>
); );
} }

View File

@@ -1,22 +1,49 @@
import { Dimensions, StyleSheet } from "react-native";
import { Colors } from "@/constants/colors"; import { Colors } from "@/constants/colors";
import { Dimensions, StyleSheet } from "react-native";
const styles = StyleSheet.create({ const styles = StyleSheet.create({
mainContainer: { mainContainer: {
flex: 1, flex: 1,
backgroundColor: Colors.header, backgroundColor: Colors.background,
paddingTop: 20,
}, },
closeIcon: { profileHero: {
position: "absolute", alignItems: "center",
top: Dimensions.get("window").height * 0.065, paddingTop: 8,
right: 15, paddingBottom: 20,
},
profileImageContainer: {
width: 120,
height: 120,
borderRadius: 60,
overflow: "hidden",
borderWidth: 3,
borderColor: "rgba(25,158,219,0.4)",
shadowColor: "#000",
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
},
profileImage: {
width: "100%",
height: "100%",
}, },
participantName: { participantName: {
color: Colors.text, color: Colors.text,
fontSize: 20, fontSize: 24,
fontWeight: "600", fontWeight: "700",
textAlign: "center", textAlign: "center",
marginTop: Dimensions.get("window").height * 0.06, marginTop: 16,
letterSpacing: 0.3,
},
participantSubtitle: {
color: "rgba(255,255,255,0.5)",
fontSize: 14,
fontWeight: "500",
textAlign: "center",
marginTop: 4,
letterSpacing: 0.2,
}, },
participantImage: { participantImage: {
width: "100%", width: "100%",
@@ -36,36 +63,34 @@ const styles = StyleSheet.create({
marginTop: 5, marginTop: 5,
}, },
participantInfo: { participantInfo: {
color: Colors.textSecondary, color: "rgba(255,255,255,0.6)",
fontSize: 16, fontSize: 15,
textAlign: "center", textAlign: "center",
}, },
dot: { dot: {
width: 4, width: 4,
height: 4, height: 4,
borderRadius: 3, borderRadius: 2,
backgroundColor: Colors.textSecondary, backgroundColor: "rgba(255,255,255,0.3)",
marginHorizontal: 7, marginHorizontal: 7,
marginTop: 2,
}, },
performedShowsSection: { performedShowsSection: {
width: "100%", width: "100%",
height: "100%", backgroundColor: "transparent",
backgroundColor: Colors.background, paddingBottom: 40,
marginTop: 20,
}, },
performedShowsTitle: { performedShowsTitle: {
fontSize: 16, fontSize: 18,
fontWeight: "600", fontWeight: "700",
color: Colors.textSecondary, color: Colors.text,
marginTop: 15, marginTop: 8,
marginLeft: 15, marginLeft: 16,
marginBottom: 4,
letterSpacing: 0.2,
}, },
showImage: { showImage: {
width: "100%", width: "100%",
height: "100%", height: "100%",
borderRadius: 10,
}, },
showLabel: { showLabel: {
color: Colors.text, color: Colors.text,
@@ -85,102 +110,165 @@ const styles = StyleSheet.create({
}, },
showTitle: { showTitle: {
color: Colors.text, color: Colors.text,
fontSize: 12, fontSize: 16,
fontWeight: "600", fontWeight: "700",
textAlign: "center", letterSpacing: 0.1,
marginTop: 15,
}, },
showSeason: { showSeason: {
color: Colors.textSecondary, color: "rgba(255,255,255,0.45)",
fontSize: 12, fontSize: 13,
fontWeight: "400", fontWeight: "500",
textAlign: "center",
marginTop: 5,
}, },
showContainer: { showContainer: {
width: Dimensions.get("window").width - 75, width: Dimensions.get("window").width - 64,
height: 200, height: 180,
borderRadius: 15, borderTopLeftRadius: 20,
marginTop: 20, borderTopRightRadius: 20,
alignItems: "center", overflow: "hidden",
backgroundColor: Colors.primary,
}, },
card: { card: {
width: Dimensions.get("window").width - 75, width: Dimensions.get("window").width - 64,
alignItems: "center", borderRadius: 20,
backgroundColor: "rgba(255,255,255,0.06)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255,255,255,0.08)",
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 10,
elevation: 4,
}, },
cardInfo: {
horizontalLine: { padding: 16,
height: 50, gap: 4,
width: 2,
backgroundColor: Colors.textSecondary,
marginTop: 10,
alignSelf: "center",
}, },
partnerLabel: { cardTitleRow: {
color: Colors.textSecondary, flexDirection: "row",
fontSize: 12, alignItems: "flex-start",
fontWeight: "400", gap: 12,
textAlign: "center",
marginTop: 10,
}, },
participantContainer: { cardSearchButton: {
width: "auto", width: 34,
minHeight: "auto", height: 34,
borderRadius: 15, borderRadius: 17,
marginTop: 15, backgroundColor: "rgba(25,158,219,0.15)",
alignItems: "center", borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(25,158,219,0.25)",
justifyContent: "center", justifyContent: "center",
backgroundColor: Colors.header, alignItems: "center",
padding: 10, marginTop: 2,
}, },
sectionLabel: {
participantLabel: { color: "rgba(255,255,255,0.45)",
color: Colors.text, fontSize: 11,
fontSize: 12, fontWeight: "600",
letterSpacing: 0.4,
textTransform: "uppercase",
marginBottom: 8,
},
partnerSection: {
marginTop: 14,
paddingTop: 14,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "rgba(255,255,255,0.08)",
},
partnerRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
partnerChip: {
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 20,
backgroundColor: "rgba(231,76,139,0.15)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(231,76,139,0.3)",
},
partnerChipText: {
color: "rgba(255,255,255,0.9)",
fontSize: 13,
fontWeight: "600",
},
participantsSection: {
marginTop: 14,
paddingTop: 14,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "rgba(255,255,255,0.08)",
}, },
participantRow: { participantRow: {
flexDirection: "row", flexDirection: "row",
flexWrap: "wrap", flexWrap: "wrap",
gap: 6, gap: 6,
alignItems: "center",
justifyContent: "flex-start",
}, },
participantChip: { participantChip: {
paddingVertical: 4, paddingVertical: 5,
paddingHorizontal: 8, paddingHorizontal: 10,
borderRadius: 12, borderRadius: 14,
backgroundColor: "hsl(221, 39%, 18%)", backgroundColor: "rgba(255,255,255,0.08)",
maxWidth: 160, maxWidth: 160,
}, },
participantChipText: { participantChipText: {
color: "hsl(0, 0%, 85%)", color: "rgba(255,255,255,0.7)",
fontSize: 11, fontSize: 11,
fontWeight: "500",
}, },
moreChip: { moreChip: {
paddingVertical: 4, paddingVertical: 5,
paddingHorizontal: 10, paddingHorizontal: 12,
borderRadius: 12, borderRadius: 16,
backgroundColor: "hsl(221, 39%, 28%)", backgroundColor: "rgba(25,158,219,0.2)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(25,158,219,0.3)",
}, },
moreChipText: { moreChipText: {
color: Colors.text, color: "#199edb",
fontSize: 11, fontSize: 11,
fontWeight: "600", fontWeight: "600",
}, },
heroButtons: {
flexDirection: "row",
gap: 10,
marginTop: 16,
},
searchButton: { searchButton: {
width: 50, flexDirection: "row",
height: 50, alignItems: "center",
borderRadius: 20, gap: 8,
backgroundColor: Colors.header, paddingVertical: 10,
marginLeft: 15, paddingHorizontal: 18,
marginTop: 15, borderRadius: 22,
marginBottom: 5, backgroundColor: "rgba(25,158,219,0.15)",
justifyContent: "center", borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(25,158,219,0.3)",
},
searchButtonText: {
color: "#199edb",
fontSize: 14,
fontWeight: "600",
},
instagramButton: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 22,
backgroundColor: "rgba(225,48,108,0.12)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(225,48,108,0.3)",
},
instagramButtonText: {
color: "#E1306C",
fontSize: 14,
fontWeight: "600",
},
loadingContainer: {
paddingVertical: 60,
alignItems: "center", alignItems: "center",
}, },
}); });

View File

@@ -1,10 +1,20 @@
import { StyleSheet } from "react-native";
import { Colors } from "@/constants/colors"; import { Colors } from "@/constants/colors";
import { Dimensions, StyleSheet } from "react-native";
const styles = StyleSheet.create({ const styles = StyleSheet.create({
mainContainer: { mainContainer: {
flex: 1, flex: 1,
backgroundColor: Colors.header, backgroundColor: Colors.background,
paddingTop: Dimensions.get("screen").height * 0.1,
},
logoContainer: {
alignItems: "center",
paddingTop: 8,
paddingBottom: 4,
},
showLogo: {
width: 100,
height: 80,
}, },
showImage: { showImage: {
width: 200, width: 200,
@@ -14,136 +24,168 @@ const styles = StyleSheet.create({
bottom: 10, bottom: 10,
}, },
showMainInfoSection: { showMainInfoSection: {
width: "auto",
height: "auto",
alignSelf: "center", alignSelf: "center",
flexDirection: "row", flexDirection: "row",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
bottom: 25, gap: 8,
marginBottom: 8,
}, },
showInfoText: { showInfoText: {
color: Colors.textSecondary, color: "rgba(255,255,255,0.7)",
fontSize: 14, fontSize: 13,
fontWeight: "500",
}, },
dot: { dot: {
width: 4, width: 4,
height: 4, height: 4,
borderRadius: 3, borderRadius: 2,
backgroundColor: Colors.textSecondary, backgroundColor: "rgba(255,255,255,0.3)",
marginHorizontal: 7, marginHorizontal: 6,
marginTop: 2,
}, },
showBannerLogoContainer: { showBannerLogoContainer: {
width: "100%", width: "100%",
height: 200, height: 220,
alignSelf: "center", alignSelf: "center",
borderTopLeftRadius: 80, marginTop: 8,
borderTopRightRadius: 80,
marginTop: 15,
}, },
showBannerLogo: { showBannerLogo: {
width: "100%", width: "100%",
height: "100%", height: "100%",
borderTopLeftRadius: 30, borderTopLeftRadius: 28,
borderTopRightRadius: 30, borderTopRightRadius: 28,
}, },
infoContainner: { infoContainner: {
width: "100%", width: "100%",
minHeight: "auto", minHeight: "auto",
paddingHorizontal: 20, paddingHorizontal: 20,
paddingVertical: 15, paddingVertical: 14,
backgroundColor: Colors.background, backgroundColor: "transparent",
flexDirection: "row", flexDirection: "row",
gap: 20, gap: 6,
}, },
infoLabel: { infoLabel: {
fontWeight: "300", fontWeight: "500",
color: Colors.textSecondary, color: "rgba(255,255,255,0.5)",
fontSize: 16, fontSize: 15,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
overflow: "hidden",
letterSpacing: 0.3,
}, },
participantsDetailsContainer: { participantsDetailsContainer: {
width: "100%", width: "100%",
height: "100%", minHeight: 200,
backgroundColor: Colors.card, backgroundColor: "transparent",
},
participantWrapper: {
width: (Dimensions.get("window").width - 32 - 24) / 3,
alignItems: "center",
marginBottom: 16,
}, },
participantContainer: { participantContainer: {
height: 160, width: "100%",
width: 110, aspectRatio: 0.72,
backgroundColor: Colors.primary, borderRadius: 16,
borderRadius: 10, overflow: "hidden",
marginBottom: 30, shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 4,
}, },
participantSection: { participantSection: {
flexDirection: "row", flexDirection: "row",
flexWrap: "wrap", flexWrap: "wrap",
gap: 15, gap: 12,
paddingLeft: 15, paddingHorizontal: 16,
paddingTop: 15, paddingTop: 12,
}, },
seasonsSection: { seasonsSection: {
width: "100%", width: "100%",
minHeight: 40, minHeight: 50,
backgroundColor: Colors.card, backgroundColor: "transparent",
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 10, gap: 12,
paddingHorizontal: 20, paddingHorizontal: 20,
paddingVertical: 8,
}, },
seasonList: { seasonList: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 10, gap: 8,
paddingLeft: 5, paddingLeft: 4,
paddingRight: 5, paddingRight: 8,
}, },
seasonContainer: { seasonContainer: {
width: 35, width: 40,
height: 35, height: 40,
borderRadius: 5, borderRadius: 20,
backgroundColor: "hsl(0, 0%, 20%)", backgroundColor: "rgba(255,255,255,0.08)",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255,255,255,0.06)",
}, },
seasonLabel: { seasonLabel: {
color: Colors.text, color: Colors.text,
fontWeight: "bold", fontWeight: "700",
fontSize: 14,
}, },
participantLabel: { participantLabel: {
color: Colors.text, color: Colors.text,
fontWeight: "500", fontWeight: "600",
textAlign: "center", textAlign: "center",
fontSize: 11, fontSize: 12,
marginTop: 10, marginTop: 6,
letterSpacing: 0.1,
width: "100%",
}, },
seasonsLabel: { seasonsLabel: {
color: Colors.textSecondary, color: "rgba(255,255,255,0.6)",
fontWeight: "500", fontWeight: "600",
fontSize: 16, fontSize: 15,
letterSpacing: 0.2,
}, },
detailTitle: { detailTitle: {
color: Colors.text, color: "rgba(255,255,255,0.95)",
fontSize: 14, fontSize: 15,
fontWeight: "bold", fontWeight: "700",
marginTop: 10, marginTop: 10,
marginLeft: 20, marginLeft: 20,
marginBottom: 5, marginBottom: 5,
letterSpacing: 0.2,
}, },
detailLabel: { detailLabel: {
color: Colors.textSecondary, color: "rgba(255,255,255,0.6)",
fontSize: 14, fontSize: 14,
lineHeight: 20, lineHeight: 22,
width: "90%", width: "90%",
fontWeight: "300", fontWeight: "400",
marginLeft: 20, marginLeft: 20,
marginTop: 5, marginTop: 5,
}, },
startDate: { startDate: {
color: Colors.textSecondary, color: "rgba(255,255,255,0.5)",
fontSize: 16, fontSize: 14,
textAlign: "center", textAlign: "center",
marginTop: 15, marginTop: 14,
fontStyle: "italic", fontWeight: "500",
letterSpacing: 0.5,
textTransform: "uppercase",
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
sectionLoading: {
width: "100%",
paddingVertical: 40,
justifyContent: "center",
alignItems: "center",
}, },
}); });
export default styles; export default styles;

View File

@@ -16,7 +16,7 @@ export default StyleSheet.create({
mainContainer: { mainContainer: {
flex: 1, flex: 1,
backgroundColor: Colors.background, backgroundColor: Colors.background,
paddingHorizontal: 5, paddingHorizontal: 10,
}, },
header: { header: {
minHeight: 125, minHeight: 125,

View File

@@ -1,7 +1,7 @@
import { FontAwesome } from "@expo/vector-icons"; import Feather from "@expo/vector-icons/Feather";
import { router } from "expo-router"; import { router } from "expo-router";
import React from "react"; import React from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
export type PersonLite = { export type PersonLite = {
id?: number; id?: number;
@@ -24,66 +24,110 @@ const calcAge = (birthDate?: string | null): number | null => {
type Props = { type Props = {
person: any; person: any;
onPress?: () => void; isFirst?: boolean;
isLast?: boolean;
}; };
export default function PersonRow({ person }: Props) { export default function PersonRow({ person, isFirst, isLast }: Props) {
const age = calcAge(person.birthDate); const age = calcAge(person.birthDate);
const id = person.personId ?? person.id; const id = person.personId ?? person.id;
const imageUrl = person.imageUrl ?? person.imageUri ?? null;
const isPravatar = imageUrl?.includes("pravatar");
const goToPerson = React.useCallback( const goToPerson = React.useCallback(
(id: number) => { (id: number) => {
console.log("go to person", id);
router.push({ router.push({
pathname: "/participant", pathname: "/participant",
params: { participantId: String(id), name: person.name }, params: {
participantId: String(id),
name: person.name,
imageUri: imageUrl || "",
},
}); });
}, },
[person.name] [person.name, imageUrl],
); );
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => goToPerson(Number(id))}
goToPerson(Number(id)); style={[
}} styles.personRow,
style={styles.personRow} isFirst && styles.firstRow,
isLast && styles.lastRow,
]}
activeOpacity={0.6}
> >
<View style={styles.avatarCircle}> <View style={styles.avatarCircle}>
<FontAwesome name="user" size={22} color="#ccc" /> {imageUrl && !isPravatar ? (
<Image
source={{ uri: imageUrl }}
style={styles.avatarImage}
resizeMode="cover"
/>
) : (
<Feather name="user" size={20} color="rgba(255,255,255,0.7)" />
)}
</View> </View>
<View style={styles.content}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={styles.personName}> <Text style={styles.personName}>{person.name || "Unbekannt"}</Text>
{person.name || "Unbekannt"} {age != null && <Text style={styles.personMeta}>{age} Jahre</Text>}
{age != null ? ` (${age})` : ""} </View>
</Text> <Feather name="chevron-right" size={16} color="rgba(255,255,255,0.3)" />
{/* <Text style={styles.personMeta}>aus: unterschiedlichen Shows</Text> */}
</View> </View>
<FontAwesome name="chevron-right" size={14} color="#888" />
</TouchableOpacity> </TouchableOpacity>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
personRow: { personRow: {
width: "100%",
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
backgroundColor: "#1b1e2b", backgroundColor: "rgba(255,255,255,0.06)",
borderRadius: 10, paddingLeft: 16,
paddingHorizontal: 10, minHeight: 56,
paddingVertical: 10, },
marginBottom: 8, firstRow: {
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
lastRow: {
borderBottomLeftRadius: 10,
borderBottomRightRadius: 10,
}, },
avatarCircle: { avatarCircle: {
width: 40, width: 36,
height: 40, height: 36,
borderRadius: 999, borderRadius: 18,
backgroundColor: "#2a2f45", backgroundColor: "rgba(255,255,255,0.1)",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
marginRight: 10, marginRight: 12,
overflow: "hidden",
},
avatarImage: {
width: 36,
height: 36,
borderRadius: 18,
},
content: {
flex: 1,
flexDirection: "row",
alignItems: "center",
paddingRight: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "rgba(255,255,255,0.08)",
},
personName: {
color: "white",
fontSize: 17,
fontWeight: "400",
},
personMeta: {
color: "rgba(255,255,255,0.5)",
fontSize: 14,
marginTop: 1,
}, },
personName: { color: "white", fontSize: 16, fontWeight: "600" },
personMeta: { color: "#bbb", fontSize: 12, marginTop: 2 },
}); });

View File

@@ -1,20 +1,45 @@
import { FontAwesome } from "@expo/vector-icons"; import Feather from "@expo/vector-icons/Feather";
import React from "react"; import React from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
export default function TagChip({ icon, label, onPress }: { icon: any; label: string; onPress: () => void }) { export default function TagChip({
icon: _icon,
label,
onPress,
}: {
icon: any;
label: string;
onPress: () => void;
}) {
return ( return (
<TouchableOpacity onPress={onPress}> <TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<View style={styles.tag}> <View style={styles.tag}>
<FontAwesome name={icon} size={16} color="#bbb" style={{ marginRight: 6 }} />
<Text style={styles.tagLabel}>{label}</Text> <Text style={styles.tagLabel}>{label}</Text>
<FontAwesome name="times-circle" size={16} color="#bbb" style={{ marginLeft: 6 }} /> <Feather
name="x"
size={14}
color="rgba(255,255,255,0.5)"
style={{ marginLeft: 4 }}
/>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
tag: { flexDirection: "row", alignItems: "center", backgroundColor: "#333", borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6, marginRight: 8, marginBottom: 8 }, tag: {
tagLabel: { color: "white" }, flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(255,255,255,0.12)",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 7,
marginRight: 8,
marginBottom: 8,
},
tagLabel: {
color: "white",
fontSize: 15,
fontWeight: "400",
},
}); });

View File

@@ -1,3 +1,4 @@
import { BlurView } from "expo-blur";
import { StyleSheet, Text, View } from "react-native"; import { StyleSheet, Text, View } from "react-native";
type ParticipantDetailsProps = { type ParticipantDetailsProps = {
@@ -14,41 +15,77 @@ const ParticipantDetails = ({
streamingService, streamingService,
}: ParticipantDetailsProps) => { }: ParticipantDetailsProps) => {
return ( return (
<View style={styles.participantsDetailsContainer}> <View style={styles.container}>
<Text style={styles.detailTitle}>Beschreibung:</Text> <BlurView intensity={20} tint="dark" style={styles.card}>
<Text style={styles.detailTitle}>Beschreibung</Text>
<Text style={styles.detailLabel}>{description}</Text> <Text style={styles.detailLabel}>{description}</Text>
<Text style={styles.detailTitle}>Konzept:</Text> </BlurView>
<BlurView intensity={20} tint="dark" style={styles.card}>
<Text style={styles.detailTitle}>Konzept</Text>
<Text style={styles.detailLabel}>{concept}</Text> <Text style={styles.detailLabel}>{concept}</Text>
<Text style={styles.detailTitle}>Genres:</Text> </BlurView>
<Text style={styles.detailLabel}>{genres.join(', ')}</Text> <BlurView intensity={20} tint="dark" style={styles.card}>
<Text style={styles.detailTitle}>Produktion:</Text> <Text style={styles.detailTitle}>Genres</Text>
<View style={styles.genreRow}>
{genres.map((g) => (
<View key={g} style={styles.genrePill}>
<Text style={styles.genrePillText}>{g}</Text>
</View>
))}
</View>
</BlurView>
<BlurView intensity={20} tint="dark" style={styles.card}>
<Text style={styles.detailTitle}>Produktion</Text>
<Text style={styles.detailLabel}>{streamingService}</Text> <Text style={styles.detailLabel}>{streamingService}</Text>
</BlurView>
</View> </View>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
participantsDetailsContainer: { container: {
width: "100%", width: "100%",
height: "100%", paddingHorizontal: 16,
backgroundColor: "hsl(221, 39%, 2%)", paddingTop: 12,
paddingBottom: 20,
gap: 12,
backgroundColor: "transparent",
},
card: {
borderRadius: 20,
overflow: "hidden",
padding: 18,
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255,255,255,0.08)",
}, },
detailTitle: { detailTitle: {
color: "hsl(0, 0%, 100%)", color: "rgba(255,255,255,0.95)",
fontSize: 14, fontSize: 15,
fontWeight: "bold", fontWeight: "700",
marginTop: 10, marginBottom: 8,
marginLeft: 20, letterSpacing: 0.2,
marginBottom: 5,
}, },
detailLabel: { detailLabel: {
color: "hsl(0, 0%, 80%)", color: "rgba(255,255,255,0.65)",
fontSize: 14, fontSize: 14,
lineHeight: 20, lineHeight: 22,
width: "90%", fontWeight: "400",
fontWeight: "300", },
marginLeft: 20, genreRow: {
marginTop: 5, flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
genrePill: {
backgroundColor: "rgba(255,255,255,0.1)",
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 16,
},
genrePillText: {
color: "rgba(255,255,255,0.8)",
fontSize: 13,
fontWeight: "500",
}, },
}); });

View File

@@ -5,7 +5,6 @@ type ShowCardProps = {
imageUri: string; imageUri: string;
streamingServicesUris: string[]; streamingServicesUris: string[];
liveBadgeText?: string; liveBadgeText?: string;
liveBadgeContainerStyle?: object;
genres: string[]; genres: string[];
title: string; title: string;
onPress?: () => void; onPress?: () => void;
@@ -15,139 +14,144 @@ const ShowCard = ({
imageUri, imageUri,
streamingServicesUris, streamingServicesUris,
liveBadgeText, liveBadgeText,
liveBadgeContainerStyle,
genres, genres,
onPress, onPress,
title, title,
}: ShowCardProps) => { }: ShowCardProps) => {
return ( return (
<TouchableOpacity <TouchableOpacity style={styles.card} activeOpacity={0.8} onPress={onPress}>
style={styles.showContainer}
activeOpacity={0.3}
onPress={onPress}
>
<Image <Image
source={{ source={{ uri: imageUri }}
uri: imageUri, style={[StyleSheet.absoluteFillObject, { borderRadius: 18 }]}
}}
style={[StyleSheet.absoluteFillObject, { borderRadius: 35 }]}
/> />
<View style={{ flexDirection: 'row', width: '100%', justifyContent: 'flex-end', padding: 10, gap: 5}}> {/* Gradient-like overlay at bottom */}
{streamingServicesUris.length > 0 && streamingServicesUris.map((service) => ( <View style={styles.bottomGradient} />
{/* Streaming service icons */}
<View style={styles.serviceRow}>
{streamingServicesUris.length > 0 &&
streamingServicesUris.map((service) => (
<Image <Image
key={service} key={service}
source={{ source={{ uri: service }}
uri: service, style={styles.serviceIcon}
}}
style={{ height: 45, width: 45, resizeMode: 'contain', borderRadius: 100}}
/> />
))} ))}
</View> </View>
{/* Live badge */}
{liveBadgeText && ( {liveBadgeText && (
<View style={liveBadgeContainerStyle}> <View style={styles.liveBadge}>
<View style={styles.liveDot} />
<Text style={styles.liveBadgeText}>{liveBadgeText}</Text> <Text style={styles.liveBadgeText}>{liveBadgeText}</Text>
</View> </View>
)} )}
<View style={styles.titleSection}> {/* Bottom info */}
<Text <View style={styles.bottomInfo}>
style={{ <Text style={styles.title} numberOfLines={1}>
color: "white",
fontWeight: "bold",
fontSize: 12,
}}
>
{title} {title}
</Text> </Text>
</View> {genres.length > 0 && (
<View style={styles.genreSection}> <View style={styles.genreRow}>
{genres.map((genre) => ( {genres.slice(0, 3).map((genre) => (
<Text key={genre} style={styles.genreLabel}> <Text key={genre} style={styles.genreTag}>
{genre} {genre}
</Text> </Text>
))} ))}
</View> </View>
)}
</View>
</TouchableOpacity> </TouchableOpacity>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
showContainer: { card: {
width: "100%", width: "100%",
height: 220, height: 200,
borderRadius: 18,
marginTop: 14,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.06)",
},
bottomGradient: {
...StyleSheet.absoluteFillObject,
borderRadius: 18,
backgroundColor: "transparent", backgroundColor: "transparent",
alignSelf: "center", // A dark gradient from bottom for readability
borderRadius: 35, // Using a semi-transparent overlay at bottom
marginTop: 20,
borderWidth: 1.5,
borderColor: "hsl(221, 39%, 15%)",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 1,
}, },
shadowOpacity: 0.18, serviceRow: {
shadowRadius: 1.0, flexDirection: "row",
elevation: 1, justifyContent: "flex-end",
padding: 10,
gap: 6,
}, },
streamingServiceIcon: { serviceIcon: {
width: 45, height: 34,
height: 45, width: 34,
marginLeft: "auto", borderRadius: 17,
marginRight: 15, resizeMode: "contain",
borderWidth: 1, backgroundColor: "rgba(0,0,0,0.3)",
borderColor: "white",
borderRadius: 15,
marginTop: 15,
}, },
liveBadgeContainer: { liveBadge: {
position: "absolute", position: "absolute",
top: 15, top: 12,
left: 20, left: 12,
backgroundColor: "red", flexDirection: "row",
borderRadius: 10, alignItems: "center",
paddingVertical: 5, gap: 5,
backgroundColor: "rgba(0,0,0,0.55)",
paddingVertical: 4,
paddingHorizontal: 10, paddingHorizontal: 10,
borderRadius: 12,
},
liveDot: {
width: 7,
height: 7,
borderRadius: 4,
backgroundColor: "#ff3b30",
}, },
liveBadgeText: { liveBadgeText: {
color: "white", color: "white",
fontWeight: "bold", fontWeight: "700",
fontSize: 11,
letterSpacing: 0.5,
}, },
genreSection: { bottomInfo: {
position: "absolute", position: "absolute",
bottom: 15, bottom: 0,
left: 20, left: 0,
right: 0,
paddingHorizontal: 14,
paddingBottom: 12,
paddingTop: 24,
backgroundColor: "rgba(0,0,0,0.45)",
},
title: {
color: "white",
fontWeight: "700",
fontSize: 16,
letterSpacing: 0.2,
},
genreRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", gap: 6,
justifyContent: "space-evenly", marginTop: 5,
gap: 5, flexWrap: "wrap",
}, },
genreLabel: { genreTag: {
color: "red", color: "rgba(255,255,255,0.8)",
fontWeight: "bold", fontSize: 11,
fontSize: 10, fontWeight: "500",
paddingVertical: 5, paddingVertical: 2,
paddingHorizontal: 10, paddingHorizontal: 8,
borderRadius: 10, borderRadius: 8,
fontStyle: "italic", backgroundColor: "rgba(255,255,255,0.15)",
backgroundColor: "rgba(255, 255, 255, 1)",
overflow: "hidden", overflow: "hidden",
}, },
titleSection: {
width: "auto",
height: 45,
paddingHorizontal: 20,
backgroundColor: "rgba(0, 0, 0, 0.6)",
position: "absolute",
top: 50,
justifyContent: "center",
alignItems: "flex-start",
borderTopRightRadius: 15,
borderBottomRightRadius: 15,
},
}); });
export default ShowCard; export default ShowCard;

View File

@@ -1,4 +1,5 @@
import { View, Text, StyleSheet } from "react-native"; import { BlurView } from "expo-blur";
import { StyleSheet, Text, View } from "react-native";
type ShowInfoProps = { type ShowInfoProps = {
seasons: number; seasons: number;
@@ -17,37 +18,42 @@ const ShowInfo = ({
}: ShowInfoProps) => { }: ShowInfoProps) => {
return ( return (
<View style={styles.showMainInfoSection}> <View style={styles.showMainInfoSection}>
<BlurView intensity={25} tint="dark" style={styles.pill}>
<Text style={styles.showInfoText}>{seasons} Staffeln</Text> <Text style={styles.showInfoText}>{seasons} Staffeln</Text>
<View style={styles.dot} /> </BlurView>
<BlurView intensity={25} tint="dark" style={styles.pill}>
<Text style={styles.showInfoText}>{participants} Teilnehmer</Text> <Text style={styles.showInfoText}>{participants} Teilnehmer</Text>
<View style={styles.dot} /> </BlurView>
<BlurView intensity={25} tint="dark" style={styles.pill}>
<Text style={styles.showInfoText}>{streamingService}</Text> <Text style={styles.showInfoText}>{streamingService}</Text>
</BlurView>
</View> </View>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
showMainInfoSection: { showMainInfoSection: {
width: "auto",
height: "auto",
alignSelf: "center", alignSelf: "center",
flexDirection: "row", flexDirection: "row",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
top: 20, gap: 8,
marginBottom: 20, marginTop: 8,
marginBottom: 12,
},
pill: {
paddingHorizontal: 14,
paddingVertical: 7,
borderRadius: 20,
overflow: "hidden",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255,255,255,0.1)",
}, },
showInfoText: { showInfoText: {
color: "hsl(0, 0%, 80%)", color: "rgba(255,255,255,0.85)",
fontSize: 14, fontSize: 13,
}, fontWeight: "500",
dot: { letterSpacing: 0.2,
width: 4,
height: 4,
borderRadius: 3,
backgroundColor: "hsl(0, 0%, 80%)",
marginHorizontal: 7,
marginTop: 2,
}, },
}); });

View File

@@ -1,4 +1,5 @@
import Feather from "@expo/vector-icons/Feather"; import Feather from "@expo/vector-icons/Feather";
import { BlurView } from "expo-blur";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import React from "react"; import React from "react";
import { import {
@@ -15,48 +16,51 @@ export default function StackHeader() {
const logoUriString = Array.isArray(logoUri) ? logoUri[0] : logoUri; const logoUriString = Array.isArray(logoUri) ? logoUri[0] : logoUri;
return ( return (
<View style={styles.header}> <BlurView intensity={60} tint="dark" style={styles.header}>
<TouchableOpacity onPress={() => router.back()}> <TouchableOpacity onPress={() => router.back()}>
<Feather name="arrow-left" size={26} color="white" /> <BlurView intensity={40} tint="light" style={styles.backButton}>
<Feather
name="chevron-left"
size={22}
color="rgba(255,255,255,0.95)"
/>
</BlurView>
</TouchableOpacity> </TouchableOpacity>
<Image style={styles.logo} source={{ uri: logoUriString }} /> <Image style={styles.logo} source={{ uri: logoUriString }} />
{/* <TouchableOpacity> <View style={{ width: 40 }} />
<Feather name="share" size={26} color="white" /> </BlurView>
</TouchableOpacity> */}
</View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: { header: {
height: 150, height: 140,
backgroundColor: "hsl(221, 39%, 12%)",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
flexDirection: "row", flexDirection: "row",
borderBottomWidth: 1, paddingTop: Dimensions.get("window").height * 0.06,
paddingTop: Dimensions.get("window").height * 0.065, paddingHorizontal: 16,
paddingHorizontal: 20, borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "rgba(255,255,255,0.08)",
borderBottomColor: "hsl(221, 39%, 15%)",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 3,
}, },
shadowOpacity: 0.25, backButton: {
shadowRadius: 3.84, width: 40,
elevation: 5, height: 40,
borderRadius: 20,
overflow: "hidden",
justifyContent: "center",
alignItems: "center",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(255,255,255,0.18)",
}, },
logo: { logo: {
width: 100, width: 100,
height: 100, height: 100,
resizeMode: "contain", resizeMode: "contain",
marginLeft: 10,
}, },
title: { title: {
color: "white", color: "white",
fontSize: 14, fontSize: 14,
fontWeight: "bold", fontWeight: "600",
}, },
}); });

View File

@@ -1,10 +1,10 @@
import { discoverSearch } from "@/apis/searchApi";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getSearchResults } from "@/apis/searchApi";
export const useSearch = (tags: string[]) => { export const useSearch = (tags: string[]) => {
return useQuery({ return useQuery({
queryKey: ["search", tags], queryKey: ["search", tags],
queryFn: () => getSearchResults(tags), queryFn: () => discoverSearch(tags),
enabled: tags.length > 0, enabled: tags.length > 0,
}); });
}; };

3463
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,19 +20,19 @@
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.5",
"expo": "54.0.21", "expo": "~54.0.33",
"expo-blur": "~15.0.7", "expo-blur": "~15.0.8",
"expo-constants": "~18.0.10", "expo-constants": "~18.0.10",
"expo-font": "~14.0.8", "expo-font": "~14.0.8",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.10", "expo-image": "~3.0.11",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.11",
"expo-router": "~6.0.14", "expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.10", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.8", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.7", "expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.8", "expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.10",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
@@ -46,6 +46,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@react-native-community/cli": "^20.0.2",
"@types/react": "~19.1.10", "@types/react": "~19.1.10",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",

View File

@@ -29,7 +29,7 @@ export function mapApiPersonToUI(data: any) {
export function mapApiSeasonToUI(data: any) { export function mapApiSeasonToUI(data: any) {
return { return {
seasonId: data?.seasonId ?? data?.id, seasonId: data?.seasonId ?? data?.id,
showId: data?.showId, showId: data?.showId ?? data?.show,
startDate: data?.startDate ?? null, startDate: data?.startDate ?? null,
endDate: data?.endDate ?? null, endDate: data?.endDate ?? null,
seasonNumber: data?.seasonNumber ?? null, seasonNumber: data?.seasonNumber ?? null,
@@ -40,12 +40,20 @@ export function mapApiSeasonToUI(data: any) {
} }
export function mapApiShowToUI(data: any) { export function mapApiShowToUI(data: any) {
const id = data?.showId ?? data?.id;
const genre = data?.genre ?? "";
return { return {
id: data.showId ?? data.id, // <-- hier der Fix id,
title: data?.title ?? data?.name ?? `Show #${data?.showId ?? data?.id ?? "?"}`, showId: id,
title: data?.title ?? data?.name ?? `Show #${id ?? "?"}`,
description: data?.description ?? "", description: data?.description ?? "",
genre: data?.genre ?? "", genres: genre ? genre.split(",").map((g: string) => g.trim()) : [],
genre,
thumbnailUri: data?.thumbnailUrl ?? data?.imageUrl ?? "", thumbnailUri: data?.thumbnailUrl ?? data?.imageUrl ?? "",
bannerUri: data?.bannerUrl ?? "",
streamingService: data?.streamingServices ?? "",
concept: data?.concept ?? "",
running: data?.running ?? false, running: data?.running ?? false,
logoUrl: data?.logoUrl ?? "",
} as any; } as any;
} }