modified: files to ios26 ui/ux

This commit is contained in:
Yordan Simeonov
2026-03-11 13:43:06 +11:00
parent 44e3558681
commit c67e60a57b
23 changed files with 2310 additions and 1618 deletions

View File

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

View File

@@ -1,40 +1,45 @@
import Feather from "@expo/vector-icons/Feather";
import { Tabs } from "expo-router";
import React from "react";
import * as Haptics from "expo-haptics";
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() {
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 (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "#dc2626",
tabBarStyle: {
backgroundColor: "hsl(221, 39%, 12%)",
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
name="explore"
options={{
title: "Durchsuchen",
tabBarIcon: ({ color, size }) => (
<Feather name="search" size={size} color={color} />
),
}}
/>
</Tabs>
<NativeTabs>
<NativeTabs.Trigger name="home">
<Label>Home</Label>
<Icon
sf={{ default: "house", selected: "house.fill" }}
androidSrc={<VectorIcon family={Feather} name="home" />}
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<Label>Durchsuchen</Label>
<Icon
sf="magnifyingglass"
androidSrc={<VectorIcon family={Feather} name="search" />}
/>
</NativeTabs.Trigger>
</NativeTabs>
);
}

View File

@@ -1,443 +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,
// isLoading, // optional, falls benötigt
} = 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]);
// 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;
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 && (
<Text style={styles.sectionTitle}>Staffeln</Text>
)}
{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} />
)}
/>
);
})}
{/* 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,356 +1,5 @@
import styles from "@/app/tabStyles/indexStyles";
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";
import { Redirect } from "expo-router";
export default function HomeScreen() {
const {
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="info" 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>
);
export default function () {
return <Redirect href="/home" />;
}

View File

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

View File

@@ -1,154 +1,227 @@
import { Colors } from "@/constants/colors";
import Feather from "@expo/vector-icons/Feather";
import { BlurView } from "expo-blur";
import { router } from "expo-router";
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() {
return (
<View style={{ flex: 1, backgroundColor: "hsl(220, 15%, 10%)" }}>
<View
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>
<View style={styles.container}>
{/* Header */}
<BlurView intensity={40} tint="dark" style={styles.header}>
<Text style={styles.headerTitle}>Info</Text>
<TouchableOpacity
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel="Modal schließen"
style={{
height: 40,
width: 40,
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "rgba(255,255,255,0.08)",
}}
style={styles.closeButton}
>
<Feather name="x" size={22} color="#FFFFFF" />
<Feather name="x" size={18} color="rgba(255,255,255,0.85)" />
</TouchableOpacity>
</View>
</BlurView>
<ScrollView
contentContainerStyle={{
paddingHorizontal: 16,
paddingBottom: 28,
}}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* Impressum Card */}
<View
style={{
backgroundColor: "rgba(255,255,255,0.05)",
borderRadius: 14,
padding: 16,
gap: 10,
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>
{/* Impressum */}
<View style={styles.card}>
<View style={styles.cardHeader}>
<View style={styles.cardIconCircle}>
<Feather name="briefcase" size={16} color="#199edb" />
</View>
<Text style={styles.cardTitle}>Impressum</Text>
</View>
<View style={{ height: 8 }} />
<View style={{ gap: 4 }}>
<Text style={styles.dim}>+49 1522 5642948</Text>
<Text style={styles.dim}>kontakt@berg-autosoft.de</Text>
<View style={styles.cardSection}>
<Text style={styles.textPrimary}>Berg Autosoft</Text>
<Text style={styles.textPrimary}>Joe Felipe Berg</Text>
<Text style={styles.textPrimary}>Stöckener Straße 35</Text>
<Text style={styles.textPrimary}>30419 Hannover</Text>
</View>
<View style={{ height: 8 }} />
<View style={styles.divider} />
<View style={{ gap: 4 }}>
<Text style={styles.dim}>Steuernummer: 25/103/17193</Text>
<Text style={styles.dim}>USt-ID: DE361689728</Text>
<View style={styles.cardSection}>
<View style={styles.infoRow}>
<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 style={styles.divider} />
<View style={styles.cardSection}>
<Text style={styles.textDim}>Steuernummer: 25/103/17193</Text>
<Text style={styles.textDim}>USt-ID: DE361689728</Text>
</View>
</View>
{/* Support Card */}
<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>
{/* 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.body}>
<Text style={styles.textBody}>
Sollten Sie Probleme bei der Nutzung der iOS- oder Android-App FLTR
haben, wenden Sie sich bitte direkt an den Support.
</Text>
<View style={{ height: 6 }} />
<View style={styles.divider} />
<Text style={styles.body}>Schreiben Sie eine E-Mail an:</Text>
<Text style={[styles.mono, { fontSize: 15 }]}>
developer@berg-autosoft.de
</Text>
<Text style={styles.textDim}>Schreiben Sie eine E-Mail an:</Text>
<View style={styles.emailPill}>
<Feather name="mail" size={14} color="#199edb" />
<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.
</Text>
</View>
{/* Footer */}
<View style={{ alignItems: "center", marginTop: 18 }}>
<Text
style={{
color: "rgba(255,255,255,0.6)",
fontSize: 12,
letterSpacing: 0.2,
}}
>
© 2025 Berg Autosoft
</Text>
</View>
<Text style={styles.footer}>© 2025 Berg Autosoft</Text>
</ScrollView>
</View>
);
}
const styles = {
mono: {
const styles = StyleSheet.create({
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)",
fontSize: 16,
} as const,
dim: {
fontSize: 15,
},
textSecondary: {
color: "rgba(255,255,255,0.75)",
fontSize: 14,
} as const,
body: {
color: "rgba(255,255,255,0.88)",
},
textDim: {
color: "rgba(255,255,255,0.5)",
fontSize: 13,
},
textBody: {
color: "rgba(255,255,255,0.8)",
fontSize: 14,
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 styles from "@/app/stackStyles/participantStyles";
import { usePersonHistory, AppearanceGroup } from "@/hooks/usePersonHistory";
import { usePersonHistory } from "@/hooks/usePersonHistory";
import Ionicons from "@expo/vector-icons/Ionicons";
import { router, useLocalSearchParams } from "expo-router";
import * as WebBrowser from "expo-web-browser";
import React from "react";
import { Image, Text, TouchableOpacity, View } from "react-native";
import {
ActivityIndicator,
Image,
Text,
TouchableOpacity,
View,
} from "react-native";
import {
GestureHandlerRootView,
ScrollView,
} from "react-native-gesture-handler";
export default function ParticipantScreen() {
const { name, participantId } = useLocalSearchParams();
const { name, participantId, imageUri } = useLocalSearchParams();
const pid = Array.isArray(participantId)
? Number(participantId[0])
: Number(participantId);
const imageUriString = Array.isArray(imageUri) ? imageUri[0] : imageUri;
const isPravatar = imageUriString?.includes("pravatar");
const { data: appearances = [], isLoading, isError } = usePersonHistory(pid);
const formatYear = (iso?: string | null) => {
@@ -27,7 +36,7 @@ export default function ParticipantScreen() {
};
const [expandedShows, setExpandedShows] = React.useState<Set<number>>(
new Set()
new Set(),
);
const toggleExpand = React.useCallback((showId: number) => {
setExpandedShows((prev) => {
@@ -45,193 +54,240 @@ export default function ParticipantScreen() {
const goToPerson = React.useCallback(
(p: PersonMini) => {
if (!p?.personId) return;
if (p.personId === pid) return;
router.push({
pathname: "/participant",
params: { participantId: String(p.personId), name: p.name },
params: {
participantId: String(p.personId),
name: p.name,
imageUri: p.imageUrl || "",
},
});
},
[pid]
[pid],
);
return (
<GestureHandlerRootView style={styles.mainContainer}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20, paddingTop: 10}}
contentContainerStyle={{ paddingBottom: 40, paddingTop: 100 }}
>
<Text style={styles.participantName}>{name}</Text>
<TouchableOpacity
style={styles.closeIcon}
onPress={() => router.back()}
>
<Ionicons name="close-circle-outline" size={38} color="white" />
</TouchableOpacity>
{/* 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.participantSubtitle}>
{appearances.length}{" "}
{appearances.length === 1 ? "Auftritt" : "Auftritte"}
</Text>
<View style={styles.heroButtons}>
<TouchableOpacity
style={styles.searchButton}
onPress={() =>
WebBrowser.openBrowserAsync(
"https://www.google.com/search?udm=2&q=" +
encodeURIComponent(String(name)),
)
}
>
<Ionicons name="images-outline" size={18} color="#199edb" />
<Text style={styles.searchButtonText}>Bilder</Text>
</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>
<View style={styles.performedShowsSection}>
<TouchableOpacity
style={styles.searchButton}
onPress={() =>
WebBrowser.openBrowserAsync(
"https://www.google.com/search?udm=2&q=" +
encodeURIComponent(String(name))
)
}
>
<Ionicons name="images-outline" size={24} color="white" />
</TouchableOpacity>
{/* Loading */}
{isLoading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#199edb" />
</View>
)}
<Text style={styles.performedShowsTitle}>Auftritte:</Text>
{/* Appearances */}
{!isLoading && appearances.length > 0 && (
<View style={styles.performedShowsSection}>
<Text style={styles.performedShowsTitle}>Auftritte</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ flex: 1 }}
contentContainerStyle={{
gap: 20,
paddingHorizontal: 15,
paddingLeft: 30,
}}
>
{appearances.toReversed().map(({ show, seasons }) => {
const partners = Array.from(
new Map(
seasons
.map((s) => s.partner)
.filter((p): p is NonNullable<typeof p> => !!p)
.map((p) => [p.personId, p])
).values()
);
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ flex: 1 }}
contentContainerStyle={{
gap: 20,
paddingHorizontal: 16,
paddingTop: 12,
}}
>
{appearances.toReversed().map(({ show, seasons }) => {
const partners = Array.from(
new Map(
seasons
.map((s) => s.partner)
.filter((p): p is NonNullable<typeof p> => !!p)
.map((p) => [p.personId, p]),
).values(),
);
const allParticipants = Array.from(
new Map(
seasons
.flatMap((s) => s.participants)
.filter((p) => p.personId !== pid)
.map((p) => [p.personId, p])
).values()
);
const allParticipants = Array.from(
new Map(
seasons
.flatMap((s) => s.participants)
.filter((p) => p.personId !== pid)
.map((p) => [p.personId, p]),
).values(),
);
const isExpanded = expandedShows.has(show.id);
const visible = isExpanded
? allParticipants
: allParticipants.slice(0, 12);
const restCount = Math.max(
allParticipants.length - visible.length,
0
);
const isExpanded = expandedShows.has(show.id);
const visible = isExpanded
? allParticipants
: allParticipants.slice(0, 12);
const restCount = Math.max(
allParticipants.length - visible.length,
0,
);
return (
<View key={show.id} style={styles.card}>
<TouchableOpacity
style={styles.showContainer}
onPress={() => goToShow(show.id)}
>
<Image
source={{ uri: show.bannerUri || show.thumbnailUri }}
style={styles.showImage}
resizeMode="cover"
/>
</TouchableOpacity>
<Text style={styles.showTitle} numberOfLines={1}>
{show.title}
</Text>
<Text style={styles.showSeason}>
({formatYear(seasons[0]?.startDate)})
</Text>
<Text style={styles.showSeason}>
Staffel {seasons.map((s) => s.seasonNumber).join(" und ")}
</Text>
return (
<View key={show.id} style={styles.card}>
<TouchableOpacity
style={styles.showContainer}
onPress={() => goToShow(show.id)}
>
<Image
source={{ uri: show.bannerUri || show.thumbnailUri }}
style={styles.showImage}
resizeMode="cover"
/>
</TouchableOpacity>
<View style={styles.horizontalLine} />
<Text style={[styles.participantLabel, { marginTop: 10 }]}>
Weitere Teilnehmer
</Text>
<View style={styles.participantContainer}>
<View style={styles.participantRow}>
{visible.map((p) => (
<TouchableOpacity
key={p.personId}
style={styles.participantChip}
onPress={() => goToPerson(p)}
>
<Text
style={styles.participantChipText}
numberOfLines={1}
>
{p.name}
<View style={styles.cardInfo}>
<View style={styles.cardTitleRow}>
<View style={{ flex: 1 }}>
<Text style={styles.showTitle} numberOfLines={1}>
{show.title}
</Text>
</TouchableOpacity>
))}
{!isExpanded && restCount > 0 && (
<TouchableOpacity
onPress={() => toggleExpand(show.id)}
style={styles.moreChip}
>
<Text style={styles.moreChipText}>
+{restCount} mehr
<Text style={styles.showSeason}>
Staffel{" "}
{seasons.map((s) => s.seasonNumber).join(" & ")}
{" · "}
{formatYear(seasons[0]?.startDate)}
</Text>
</View>
<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>
</TouchableOpacity>
))}
</View>
</View>
)}
{isExpanded && allParticipants.length > 12 && (
<TouchableOpacity
onPress={() => toggleExpand(show.id)}
style={styles.moreChip}
>
<Text style={styles.moreChipText}>Weniger</Text>
</TouchableOpacity>
{allParticipants.length > 0 && (
<View style={styles.participantsSection}>
<Text style={styles.sectionLabel}>
Weitere Teilnehmer
</Text>
<View style={styles.participantRow}>
{visible.map((p) => (
<TouchableOpacity
key={p.personId}
style={styles.participantChip}
onPress={() => goToPerson(p)}
>
<Text
style={styles.participantChipText}
numberOfLines={1}
>
{p.name}
</Text>
</TouchableOpacity>
))}
{!isExpanded && restCount > 0 && (
<TouchableOpacity
onPress={() => toggleExpand(show.id)}
style={styles.moreChip}
>
<Text style={styles.moreChipText}>
+{restCount} mehr
</Text>
</TouchableOpacity>
)}
{isExpanded && allParticipants.length > 12 && (
<TouchableOpacity
onPress={() => toggleExpand(show.id)}
style={styles.moreChip}
>
<Text style={styles.moreChipText}>Weniger</Text>
</TouchableOpacity>
)}
</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>
);
})}
</ScrollView>
</View>
);
})}
</ScrollView>
</View>
)}
</ScrollView>
</GestureHandlerRootView>
);

View File

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

View File

@@ -1,22 +1,49 @@
import { Dimensions, StyleSheet } from "react-native";
import { Colors } from "@/constants/colors";
import { Dimensions, StyleSheet } from "react-native";
const styles = StyleSheet.create({
mainContainer: {
flex: 1,
backgroundColor: Colors.header,
backgroundColor: Colors.background,
paddingTop: 20,
},
closeIcon: {
position: "absolute",
top: Dimensions.get("window").height * 0.065,
right: 15,
profileHero: {
alignItems: "center",
paddingTop: 8,
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: {
color: Colors.text,
fontSize: 20,
fontWeight: "600",
fontSize: 24,
fontWeight: "700",
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: {
width: "100%",
@@ -36,36 +63,34 @@ const styles = StyleSheet.create({
marginTop: 5,
},
participantInfo: {
color: Colors.textSecondary,
fontSize: 16,
color: "rgba(255,255,255,0.6)",
fontSize: 15,
textAlign: "center",
},
dot: {
width: 4,
height: 4,
borderRadius: 3,
backgroundColor: Colors.textSecondary,
borderRadius: 2,
backgroundColor: "rgba(255,255,255,0.3)",
marginHorizontal: 7,
marginTop: 2,
},
performedShowsSection: {
width: "100%",
height: "100%",
backgroundColor: Colors.background,
marginTop: 20,
backgroundColor: "transparent",
paddingBottom: 40,
},
performedShowsTitle: {
fontSize: 16,
fontWeight: "600",
color: Colors.textSecondary,
marginTop: 15,
marginLeft: 15,
fontSize: 18,
fontWeight: "700",
color: Colors.text,
marginTop: 8,
marginLeft: 16,
marginBottom: 4,
letterSpacing: 0.2,
},
showImage: {
width: "100%",
height: "100%",
borderRadius: 10,
},
showLabel: {
color: Colors.text,
@@ -85,102 +110,165 @@ const styles = StyleSheet.create({
},
showTitle: {
color: Colors.text,
fontSize: 12,
fontWeight: "600",
textAlign: "center",
marginTop: 15,
fontSize: 16,
fontWeight: "700",
letterSpacing: 0.1,
},
showSeason: {
color: Colors.textSecondary,
fontSize: 12,
fontWeight: "400",
textAlign: "center",
marginTop: 5,
color: "rgba(255,255,255,0.45)",
fontSize: 13,
fontWeight: "500",
},
showContainer: {
width: Dimensions.get("window").width - 75,
height: 200,
borderRadius: 15,
marginTop: 20,
alignItems: "center",
backgroundColor: Colors.primary,
width: Dimensions.get("window").width - 64,
height: 180,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
overflow: "hidden",
},
card: {
width: Dimensions.get("window").width - 75,
alignItems: "center",
width: Dimensions.get("window").width - 64,
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,
},
horizontalLine: {
height: 50,
width: 2,
backgroundColor: Colors.textSecondary,
marginTop: 10,
alignSelf: "center",
cardInfo: {
padding: 16,
gap: 4,
},
partnerLabel: {
color: Colors.textSecondary,
fontSize: 12,
fontWeight: "400",
textAlign: "center",
marginTop: 10,
cardTitleRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: 12,
},
participantContainer: {
width: "auto",
minHeight: "auto",
borderRadius: 15,
marginTop: 15,
alignItems: "center",
cardSearchButton: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: "rgba(25,158,219,0.15)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(25,158,219,0.25)",
justifyContent: "center",
backgroundColor: Colors.header,
padding: 10,
alignItems: "center",
marginTop: 2,
},
participantLabel: {
color: Colors.text,
fontSize: 12,
sectionLabel: {
color: "rgba(255,255,255,0.45)",
fontSize: 11,
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: {
flexDirection: "row",
flexWrap: "wrap",
gap: 6,
alignItems: "center",
justifyContent: "flex-start",
},
participantChip: {
paddingVertical: 4,
paddingHorizontal: 8,
borderRadius: 12,
backgroundColor: "hsl(221, 39%, 18%)",
paddingVertical: 5,
paddingHorizontal: 10,
borderRadius: 14,
backgroundColor: "rgba(255,255,255,0.08)",
maxWidth: 160,
},
participantChipText: {
color: "hsl(0, 0%, 85%)",
color: "rgba(255,255,255,0.7)",
fontSize: 11,
fontWeight: "500",
},
moreChip: {
paddingVertical: 4,
paddingHorizontal: 10,
borderRadius: 12,
backgroundColor: "hsl(221, 39%, 28%)",
paddingVertical: 5,
paddingHorizontal: 12,
borderRadius: 16,
backgroundColor: "rgba(25,158,219,0.2)",
borderWidth: StyleSheet.hairlineWidth,
borderColor: "rgba(25,158,219,0.3)",
},
moreChipText: {
color: Colors.text,
color: "#199edb",
fontSize: 11,
fontWeight: "600",
},
heroButtons: {
flexDirection: "row",
gap: 10,
marginTop: 16,
},
searchButton: {
width: 50,
height: 50,
borderRadius: 20,
backgroundColor: Colors.header,
marginLeft: 15,
marginTop: 15,
marginBottom: 5,
justifyContent: "center",
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 22,
backgroundColor: "rgba(25,158,219,0.15)",
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",
},
});

View File

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

View File

@@ -16,7 +16,7 @@ export default StyleSheet.create({
mainContainer: {
flex: 1,
backgroundColor: Colors.background,
paddingHorizontal: 5,
paddingHorizontal: 10,
},
header: {
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 React from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
export type PersonLite = {
id?: number;
@@ -24,66 +24,110 @@ const calcAge = (birthDate?: string | null): number | null => {
type Props = {
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 id = person.personId ?? person.id;
const imageUrl = person.imageUrl ?? person.imageUri ?? null;
const isPravatar = imageUrl?.includes("pravatar");
const goToPerson = React.useCallback(
(id: number) => {
console.log("go to person", id);
router.push({
pathname: "/participant",
params: { participantId: String(id), name: person.name },
params: {
participantId: String(id),
name: person.name,
imageUri: imageUrl || "",
},
});
},
[person.name]
[person.name, imageUrl],
);
return (
<TouchableOpacity
onPress={() => {
goToPerson(Number(id));
}}
style={styles.personRow}
onPress={() => goToPerson(Number(id))}
style={[
styles.personRow,
isFirst && styles.firstRow,
isLast && styles.lastRow,
]}
activeOpacity={0.6}
>
<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 style={{ flex: 1 }}>
<Text style={styles.personName}>
{person.name || "Unbekannt"}
{age != null ? ` (${age})` : ""}
</Text>
{/* <Text style={styles.personMeta}>aus: unterschiedlichen Shows</Text> */}
<View style={styles.content}>
<View style={{ flex: 1 }}>
<Text style={styles.personName}>{person.name || "Unbekannt"}</Text>
{age != null && <Text style={styles.personMeta}>{age} Jahre</Text>}
</View>
<Feather name="chevron-right" size={16} color="rgba(255,255,255,0.3)" />
</View>
<FontAwesome name="chevron-right" size={14} color="#888" />
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
personRow: {
width: "100%",
flexDirection: "row",
alignItems: "center",
backgroundColor: "#1b1e2b",
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 10,
marginBottom: 8,
backgroundColor: "rgba(255,255,255,0.06)",
paddingLeft: 16,
minHeight: 56,
},
firstRow: {
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
lastRow: {
borderBottomLeftRadius: 10,
borderBottomRightRadius: 10,
},
avatarCircle: {
width: 40,
height: 40,
borderRadius: 999,
backgroundColor: "#2a2f45",
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(255,255,255,0.1)",
alignItems: "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 { 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 (
<TouchableOpacity onPress={onPress}>
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<View style={styles.tag}>
<FontAwesome name={icon} size={16} color="#bbb" style={{ marginRight: 6 }} />
<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>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
tag: { flexDirection: "row", alignItems: "center", backgroundColor: "#333", borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6, marginRight: 8, marginBottom: 8 },
tagLabel: { color: "white" },
tag: {
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";
type ParticipantDetailsProps = {
@@ -14,41 +15,77 @@ const ParticipantDetails = ({
streamingService,
}: ParticipantDetailsProps) => {
return (
<View style={styles.participantsDetailsContainer}>
<Text style={styles.detailTitle}>Beschreibung:</Text>
<Text style={styles.detailLabel}>{description}</Text>
<Text style={styles.detailTitle}>Konzept:</Text>
<Text style={styles.detailLabel}>{concept}</Text>
<Text style={styles.detailTitle}>Genres:</Text>
<Text style={styles.detailLabel}>{genres.join(', ')}</Text>
<Text style={styles.detailTitle}>Produktion:</Text>
<Text style={styles.detailLabel}>{streamingService}</Text>
<View style={styles.container}>
<BlurView intensity={20} tint="dark" style={styles.card}>
<Text style={styles.detailTitle}>Beschreibung</Text>
<Text style={styles.detailLabel}>{description}</Text>
</BlurView>
<BlurView intensity={20} tint="dark" style={styles.card}>
<Text style={styles.detailTitle}>Konzept</Text>
<Text style={styles.detailLabel}>{concept}</Text>
</BlurView>
<BlurView intensity={20} tint="dark" style={styles.card}>
<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>
</BlurView>
</View>
);
};
const styles = StyleSheet.create({
participantsDetailsContainer: {
container: {
width: "100%",
height: "100%",
backgroundColor: "hsl(221, 39%, 2%)",
paddingHorizontal: 16,
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: {
color: "hsl(0, 0%, 100%)",
fontSize: 14,
fontWeight: "bold",
marginTop: 10,
marginLeft: 20,
marginBottom: 5,
color: "rgba(255,255,255,0.95)",
fontSize: 15,
fontWeight: "700",
marginBottom: 8,
letterSpacing: 0.2,
},
detailLabel: {
color: "hsl(0, 0%, 80%)",
color: "rgba(255,255,255,0.65)",
fontSize: 14,
lineHeight: 20,
width: "90%",
fontWeight: "300",
marginLeft: 20,
marginTop: 5,
lineHeight: 22,
fontWeight: "400",
},
genreRow: {
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;
streamingServicesUris: string[];
liveBadgeText?: string;
liveBadgeContainerStyle?: object;
genres: string[];
title: string;
onPress?: () => void;
@@ -15,139 +14,144 @@ const ShowCard = ({
imageUri,
streamingServicesUris,
liveBadgeText,
liveBadgeContainerStyle,
genres,
onPress,
title,
}: ShowCardProps) => {
return (
<TouchableOpacity
style={styles.showContainer}
activeOpacity={0.3}
onPress={onPress}
>
<TouchableOpacity style={styles.card} activeOpacity={0.8} onPress={onPress}>
<Image
source={{
uri: imageUri,
}}
style={[StyleSheet.absoluteFillObject, { borderRadius: 35 }]}
source={{ uri: imageUri }}
style={[StyleSheet.absoluteFillObject, { borderRadius: 18 }]}
/>
<View style={{ flexDirection: 'row', width: '100%', justifyContent: 'flex-end', padding: 10, gap: 5}}>
{streamingServicesUris.length > 0 && streamingServicesUris.map((service) => (
<Image
key={service}
source={{
uri: service,
}}
style={{ height: 45, width: 45, resizeMode: 'contain', borderRadius: 100}}
/>
{/* Gradient-like overlay at bottom */}
<View style={styles.bottomGradient} />
))}
{/* Streaming service icons */}
<View style={styles.serviceRow}>
{streamingServicesUris.length > 0 &&
streamingServicesUris.map((service) => (
<Image
key={service}
source={{ uri: service }}
style={styles.serviceIcon}
/>
))}
</View>
{/* Live badge */}
{liveBadgeText && (
<View style={liveBadgeContainerStyle}>
<View style={styles.liveBadge}>
<View style={styles.liveDot} />
<Text style={styles.liveBadgeText}>{liveBadgeText}</Text>
</View>
)}
<View style={styles.titleSection}>
<Text
style={{
color: "white",
fontWeight: "bold",
fontSize: 12,
}}
>
{/* Bottom info */}
<View style={styles.bottomInfo}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
</View>
<View style={styles.genreSection}>
{genres.map((genre) => (
<Text key={genre} style={styles.genreLabel}>
{genre}
</Text>
))}
{genres.length > 0 && (
<View style={styles.genreRow}>
{genres.slice(0, 3).map((genre) => (
<Text key={genre} style={styles.genreTag}>
{genre}
</Text>
))}
</View>
)}
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
showContainer: {
card: {
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",
alignSelf: "center",
borderRadius: 35,
marginTop: 20,
borderWidth: 1.5,
borderColor: "hsl(221, 39%, 15%)",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.18,
shadowRadius: 1.0,
elevation: 1,
// A dark gradient from bottom for readability
// Using a semi-transparent overlay at bottom
},
streamingServiceIcon: {
width: 45,
height: 45,
marginLeft: "auto",
marginRight: 15,
borderWidth: 1,
borderColor: "white",
borderRadius: 15,
marginTop: 15,
serviceRow: {
flexDirection: "row",
justifyContent: "flex-end",
padding: 10,
gap: 6,
},
liveBadgeContainer: {
serviceIcon: {
height: 34,
width: 34,
borderRadius: 17,
resizeMode: "contain",
backgroundColor: "rgba(0,0,0,0.3)",
},
liveBadge: {
position: "absolute",
top: 15,
left: 20,
backgroundColor: "red",
borderRadius: 10,
paddingVertical: 5,
top: 12,
left: 12,
flexDirection: "row",
alignItems: "center",
gap: 5,
backgroundColor: "rgba(0,0,0,0.55)",
paddingVertical: 4,
paddingHorizontal: 10,
borderRadius: 12,
},
liveDot: {
width: 7,
height: 7,
borderRadius: 4,
backgroundColor: "#ff3b30",
},
liveBadgeText: {
color: "white",
fontWeight: "bold",
fontWeight: "700",
fontSize: 11,
letterSpacing: 0.5,
},
genreSection: {
bottomInfo: {
position: "absolute",
bottom: 15,
left: 20,
bottom: 0,
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",
alignItems: "center",
justifyContent: "space-evenly",
gap: 5,
gap: 6,
marginTop: 5,
flexWrap: "wrap",
},
genreLabel: {
color: "red",
fontWeight: "bold",
fontSize: 10,
paddingVertical: 5,
paddingHorizontal: 10,
borderRadius: 10,
fontStyle: "italic",
backgroundColor: "rgba(255, 255, 255, 1)",
genreTag: {
color: "rgba(255,255,255,0.8)",
fontSize: 11,
fontWeight: "500",
paddingVertical: 2,
paddingHorizontal: 8,
borderRadius: 8,
backgroundColor: "rgba(255,255,255,0.15)",
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;

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

View File

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

View File

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

View File

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