Compare commits

..

10 Commits

Author SHA1 Message Date
Yordan Simeonov
4a642c7e5d Merge branch 'rescue-branch' 2026-03-27 01:08:59 +11:00
Yordan Simeonov
c67e60a57b modified: files to ios26 ui/ux 2026-03-11 13:43:06 +11:00
Yordan Simeonov
44e3558681 fix: deps 2026-03-10 16:21:31 +11:00
Malte Thöming
a076e856ad version change 2025-11-13 21:59:31 +01:00
DevOFVictory
9516642beb fixed search 2025-11-12 19:25:21 +01:00
DevOFVictory
b287f19686 update 2025-11-04 00:35:41 +01:00
Malte Thöming
37aa3008c6 update 2025-11-03 00:06:24 +01:00
DevOFVictory
09a58dd656 Merge branch 'master' of https://github.com/Cron1cle/fltr-app 2025-11-02 23:37:47 +01:00
DevOFVictory
63995d2be0 update 2025-11-02 23:37:44 +01:00
Malte Thöming
9b1ded46f8 update 2025-11-02 23:30:02 +01:00
28 changed files with 4932 additions and 2959 deletions

0
GEMINI.md Normal file
View File

View File

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

View File

@@ -2,15 +2,21 @@
"expo": { "expo": {
"name": "FLTR", "name": "FLTR",
"slug": "fltr-app", "slug": "fltr-app",
"version": "1.0.1", "version": "1.0.4",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "fltrapp", "scheme": "fltrapp",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": false,
"bundleIdentifier": "de.berg-autosoft.fltr" "bundleIdentifier": "de.berg-autosoft.fltr",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
}
}
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {

View File

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

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

View File

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

View File

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

View File

@@ -1,23 +1,32 @@
import { PersonMini } from "@/apis/personHistoryApi"; import { PersonMini } from "@/apis/personHistoryApi";
import styles from "@/app/stackStyles/participantStyles"; import styles from "@/app/stackStyles/participantStyles";
import { usePersonHistory, AppearanceGroup } from "@/hooks/usePersonHistory"; import { usePersonHistory } from "@/hooks/usePersonHistory";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import * as WebBrowser from "expo-web-browser"; import * as WebBrowser from "expo-web-browser";
import React from "react"; import React from "react";
import { Image, Text, TouchableOpacity, View } from "react-native"; import {
ActivityIndicator,
Image,
Text,
TouchableOpacity,
View,
} from "react-native";
import { import {
GestureHandlerRootView, GestureHandlerRootView,
ScrollView, ScrollView,
} from "react-native-gesture-handler"; } from "react-native-gesture-handler";
export default function ParticipantScreen() { export default function ParticipantScreen() {
const { name, participantId } = useLocalSearchParams(); const { name, participantId, imageUri } = useLocalSearchParams();
const pid = Array.isArray(participantId) const pid = Array.isArray(participantId)
? Number(participantId[0]) ? Number(participantId[0])
: Number(participantId); : Number(participantId);
const imageUriString = Array.isArray(imageUri) ? imageUri[0] : imageUri;
const isPravatar = imageUriString?.includes("pravatar");
const { data: appearances = [], isLoading, isError } = usePersonHistory(pid); const { data: appearances = [], isLoading, isError } = usePersonHistory(pid);
const formatYear = (iso?: string | null) => { const formatYear = (iso?: string | null) => {
@@ -27,7 +36,7 @@ export default function ParticipantScreen() {
}; };
const [expandedShows, setExpandedShows] = React.useState<Set<number>>( const [expandedShows, setExpandedShows] = React.useState<Set<number>>(
new Set() new Set(),
); );
const toggleExpand = React.useCallback((showId: number) => { const toggleExpand = React.useCallback((showId: number) => {
setExpandedShows((prev) => { setExpandedShows((prev) => {
@@ -45,193 +54,240 @@ export default function ParticipantScreen() {
const goToPerson = React.useCallback( const goToPerson = React.useCallback(
(p: PersonMini) => { (p: PersonMini) => {
if (!p?.personId) return; if (!p?.personId) return;
if (p.personId === pid) return; if (p.personId === pid) return;
router.push({ router.push({
pathname: "/participant", pathname: "/participant",
params: { participantId: String(p.personId), name: p.name }, params: {
participantId: String(p.personId),
name: p.name,
imageUri: p.imageUrl || "",
},
}); });
}, },
[pid] [pid],
); );
return ( return (
<GestureHandlerRootView style={styles.mainContainer}> <GestureHandlerRootView style={styles.mainContainer}>
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20, paddingTop: 10}} contentContainerStyle={{ paddingBottom: 40, paddingTop: 100 }}
> >
<Text style={styles.participantName}>{name}</Text> {/* Profile Hero */}
<TouchableOpacity <View style={styles.profileHero}>
style={styles.closeIcon} <View style={styles.profileImageContainer}>
onPress={() => router.back()} <Image
> source={{ uri: imageUriString || undefined }}
<Ionicons name="close-circle-outline" size={38} color="white" /> style={styles.profileImage}
</TouchableOpacity> 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}> {/* Loading */}
<TouchableOpacity {isLoading && (
style={styles.searchButton} <View style={styles.loadingContainer}>
onPress={() => <ActivityIndicator size="large" color="#199edb" />
WebBrowser.openBrowserAsync( </View>
"https://www.google.com/search?udm=2&q=" + )}
encodeURIComponent(String(name))
)
}
>
<Ionicons name="images-outline" size={24} color="white" />
</TouchableOpacity>
<Text style={styles.performedShowsTitle}>Auftritte:</Text> {/* Appearances */}
{!isLoading && appearances.length > 0 && (
<View style={styles.performedShowsSection}>
<Text style={styles.performedShowsTitle}>Auftritte</Text>
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
style={{ flex: 1 }} style={{ flex: 1 }}
contentContainerStyle={{ contentContainerStyle={{
gap: 20, gap: 20,
paddingHorizontal: 15, paddingHorizontal: 16,
paddingLeft: 30, paddingTop: 12,
}} }}
> >
{appearances.toReversed().map(({ show, seasons }) => { {appearances.toReversed().map(({ show, seasons }) => {
const partners = Array.from( const partners = Array.from(
new Map( new Map(
seasons seasons
.map((s) => s.partner) .map((s) => s.partner)
.filter((p): p is NonNullable<typeof p> => !!p) .filter((p): p is NonNullable<typeof p> => !!p)
.map((p) => [p.personId, p]) .map((p) => [p.personId, p]),
).values() ).values(),
); );
const allParticipants = Array.from( const allParticipants = Array.from(
new Map( new Map(
seasons seasons
.flatMap((s) => s.participants) .flatMap((s) => s.participants)
.filter((p) => p.personId !== pid) .filter((p) => p.personId !== pid)
.map((p) => [p.personId, p]) .map((p) => [p.personId, p]),
).values() ).values(),
); );
const isExpanded = expandedShows.has(show.id); const isExpanded = expandedShows.has(show.id);
const visible = isExpanded const visible = isExpanded
? allParticipants ? allParticipants
: allParticipants.slice(0, 12); : allParticipants.slice(0, 12);
const restCount = Math.max( const restCount = Math.max(
allParticipants.length - visible.length, allParticipants.length - visible.length,
0 0,
); );
return ( return (
<View key={show.id} style={styles.card}> <View key={show.id} style={styles.card}>
<TouchableOpacity <TouchableOpacity
style={styles.showContainer} style={styles.showContainer}
onPress={() => goToShow(show.id)} onPress={() => goToShow(show.id)}
> >
<Image <Image
source={{ uri: show.bannerUri || show.thumbnailUri }} source={{ uri: show.bannerUri || show.thumbnailUri }}
style={styles.showImage} style={styles.showImage}
resizeMode="cover" resizeMode="cover"
/> />
</TouchableOpacity> </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>
<View style={styles.horizontalLine} /> <View style={styles.cardInfo}>
<View style={styles.cardTitleRow}>
<Text style={[styles.participantLabel, { marginTop: 10 }]}> <View style={{ flex: 1 }}>
Weitere Teilnehmer <Text style={styles.showTitle} numberOfLines={1}>
</Text> {show.title}
<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}
</Text> </Text>
</TouchableOpacity> <Text style={styles.showSeason}>
))} Staffel{" "}
{seasons.map((s) => s.seasonNumber).join(" & ")}
{!isExpanded && restCount > 0 && ( {" · "}
<TouchableOpacity {formatYear(seasons[0]?.startDate)}
onPress={() => toggleExpand(show.id)}
style={styles.moreChip}
>
<Text style={styles.moreChipText}>
+{restCount} mehr
</Text> </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> </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 && ( {allParticipants.length > 0 && (
<TouchableOpacity <View style={styles.participantsSection}>
onPress={() => toggleExpand(show.id)} <Text style={styles.sectionLabel}>
style={styles.moreChip} Weitere Teilnehmer
> </Text>
<Text style={styles.moreChipText}>Weniger</Text> <View style={styles.participantRow}>
</TouchableOpacity> {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>
</View> </View>
);
{partners.length > 0 && ( })}
<> </ScrollView>
<View style={styles.horizontalLine} /> </View>
<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> </ScrollView>
</GestureHandlerRootView> </GestureHandlerRootView>
); );

View File

@@ -1,6 +1,5 @@
import ParticipantDetails from "@/components/ui/ParticipantDeatails"; import ParticipantDetails from "@/components/ui/ParticipantDeatails";
import ShowInfo from "@/components/ui/ShowInfo"; import ShowInfo from "@/components/ui/ShowInfo";
import StackHeader from "@/components/ui/StackHeader";
import { import {
useSeasonCount, useSeasonCount,
useSeasonDates, useSeasonDates,
@@ -11,9 +10,11 @@ import * as Haptics from "expo-haptics";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import React from "react"; import React from "react";
import { import {
ActivityIndicator,
Dimensions, Dimensions,
Image, Image,
ScrollView, ScrollView,
StyleSheet,
Text, Text,
TouchableOpacity, TouchableOpacity,
View, View,
@@ -21,15 +22,17 @@ import {
import styles from "./stackStyles/showDetailStyles"; import styles from "./stackStyles/showDetailStyles";
export default function ShowDetails() { export default function ShowDetails() {
const { id } = useLocalSearchParams(); const { id, logoUri } = useLocalSearchParams();
const showId = Number(id); const showId = Number(id);
const logoUriString = Array.isArray(logoUri) ? logoUri[0] : logoUri;
const [selectedParticipants, setSelectedParticipants] = const [selectedParticipants, setSelectedParticipants] =
React.useState<boolean>(true); React.useState<boolean>(true);
const [selectedSeason, setSelectedSeason] = React.useState<number>(1); const [selectedSeason, setSelectedSeason] = React.useState<number>(1);
const { data: show } = useShow(showId); const { data: show, isLoading: showLoading } = useShow(showId);
const { data: seasonCount = 0 } = useSeasonCount(showId); const { data: seasonCount = 0, isLoading: seasonCountLoading } =
useSeasonCount(showId);
const { const {
data: participants, data: participants,
isLoading: pLoading, isLoading: pLoading,
@@ -40,7 +43,7 @@ export default function ShowDetails() {
const sortedParticipants = React.useMemo(() => { const sortedParticipants = React.useMemo(() => {
return [...participants].sort((a, b) => return [...participants].sort((a, b) =>
a.name.localeCompare(b.name, "de", { sensitivity: "base" }) a.name.localeCompare(b.name, "de", { sensitivity: "base" }),
); );
}, [participants]); }, [participants]);
@@ -62,156 +65,227 @@ export default function ShowDetails() {
}, [startDate]); }, [startDate]);
const handleOpenParticipant = React.useCallback( const handleOpenParticipant = React.useCallback(
(p: { id: number; name: string }) => { (p: { id: number; name: string; imageUri?: string }) => {
router.push({ router.push({
pathname: "/participant", pathname: "/participant",
params: { params: {
participantId: p.id, participantId: p.id,
name: p.name, name: p.name,
imageUri: p.imageUri || "",
originShowId: String(showId), originShowId: String(showId),
originSeason: String(selectedSeason), originSeason: String(selectedSeason),
}, },
}); });
}, },
[showId, selectedSeason] [showId, selectedSeason],
); );
const isInitialLoading = showLoading || seasonCountLoading;
return ( return (
<View style={styles.mainContainer}> <View style={styles.mainContainer}>
<StackHeader /> {isInitialLoading ? (
<ScrollView <View style={styles.loadingContainer}>
showsVerticalScrollIndicator={false} <ActivityIndicator size="large" color="#199edb" />
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"
/>
</View> </View>
<View style={styles.infoContainner}> ) : (
<TouchableOpacity onPress={() => setSelectedParticipants(true)}> <ScrollView
<Text showsVerticalScrollIndicator={false}
style={[ contentContainerStyle={{
styles.infoLabel, paddingBottom: Dimensions.get("window").height * 0.1,
{ }}
fontWeight: selectedParticipants ? "bold" : "normal", >
color: selectedParticipants ? "#199edb" : "hsl(0, 0%, 65%)", {logoUriString ? (
}, <View style={styles.logoContainer}>
]} <Image
> source={{ uri: logoUriString }}
Teilnehmer style={styles.showLogo}
</Text> resizeMode="contain"
</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>
</View> </View>
) : null}
<View {formattedStartDate ? (
style={[ <Text style={styles.startDate}>{formattedStartDate}</Text>
styles.participantsDetailsContainer, ) : null}
styles.participantSection, <ShowInfo
]} seasons={seasonCount}
> participants={participants.length}
{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[]}
streamingService={show?.streamingService as string} 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>
); );
} }

View File

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

View File

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

View File

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

514
build/config.gypi Normal file
View File

@@ -0,0 +1,514 @@
# Do not edit. File was generated by node-gyp's "configure" step
{
"target_defaults": {
"cflags": [],
"configurations": {
"Debug": {
"v8_enable_v8_checks": 0,
"variables": {}
},
"Release": {
"v8_enable_v8_checks": 1,
"variables": {}
}
},
"default_configuration": "Release",
"defines": [],
"include_dirs": [],
"libraries": [],
"msvs_configuration_platform": "ARM64",
"xcode_configuration_platform": "arm64"
},
"variables": {
"arm_fpu": "neon",
"asan": 0,
"clang": 1,
"control_flow_guard": "false",
"coverage": "false",
"dcheck_always_on": 0,
"debug_nghttp2": "false",
"debug_node": "false",
"enable_lto": "false",
"enable_pgo_generate": "false",
"enable_pgo_use": "false",
"error_on_warn": "false",
"force_dynamic_crt": 0,
"host_arch": "arm64",
"icu_data_in": "../../deps/icu-tmp/icudt77l.dat",
"icu_endianness": "l",
"icu_gyp_path": "tools/icu/icu-generic.gyp",
"icu_path": "deps/icu-small",
"icu_small": "false",
"icu_ver_major": "77",
"libdir": "lib",
"llvm_version": "16.0",
"napi_build_version": "10",
"node_builtin_shareable_builtins": [
"deps/cjs-module-lexer/lexer.js",
"deps/cjs-module-lexer/dist/lexer.js",
"deps/undici/undici.js",
"deps/amaro/dist/index.js"
],
"node_byteorder": "little",
"node_cctest_sources": [
"src/node_snapshot_stub.cc",
"test/cctest/inspector/test_node_protocol.cc",
"test/cctest/node_test_fixture.cc",
"test/cctest/test_aliased_buffer.cc",
"test/cctest/test_base64.cc",
"test/cctest/test_base_object_ptr.cc",
"test/cctest/test_cppgc.cc",
"test/cctest/test_crypto_clienthello.cc",
"test/cctest/test_dataqueue.cc",
"test/cctest/test_environment.cc",
"test/cctest/test_inspector_socket.cc",
"test/cctest/test_inspector_socket_server.cc",
"test/cctest/test_json_utils.cc",
"test/cctest/test_linked_binding.cc",
"test/cctest/test_node_api.cc",
"test/cctest/test_node_crypto.cc",
"test/cctest/test_node_crypto_env.cc",
"test/cctest/test_node_postmortem_metadata.cc",
"test/cctest/test_node_task_runner.cc",
"test/cctest/test_path.cc",
"test/cctest/test_per_process.cc",
"test/cctest/test_platform.cc",
"test/cctest/test_quic_cid.cc",
"test/cctest/test_quic_error.cc",
"test/cctest/test_quic_tokens.cc",
"test/cctest/test_report.cc",
"test/cctest/test_sockaddr.cc",
"test/cctest/test_traced_value.cc",
"test/cctest/test_util.cc",
"test/cctest/node_test_fixture.h"
],
"node_debug_lib": "false",
"node_enable_d8": "false",
"node_enable_v8_vtunejit": "false",
"node_enable_v8windbg": "false",
"node_fipsinstall": "false",
"node_install_corepack": "true",
"node_install_npm": "true",
"node_library_files": [
"lib/_http_agent.js",
"lib/_http_client.js",
"lib/_http_common.js",
"lib/_http_incoming.js",
"lib/_http_outgoing.js",
"lib/_http_server.js",
"lib/_stream_duplex.js",
"lib/_stream_passthrough.js",
"lib/_stream_readable.js",
"lib/_stream_transform.js",
"lib/_stream_wrap.js",
"lib/_stream_writable.js",
"lib/_tls_common.js",
"lib/_tls_wrap.js",
"lib/assert.js",
"lib/assert/strict.js",
"lib/async_hooks.js",
"lib/buffer.js",
"lib/child_process.js",
"lib/cluster.js",
"lib/console.js",
"lib/constants.js",
"lib/crypto.js",
"lib/dgram.js",
"lib/diagnostics_channel.js",
"lib/dns.js",
"lib/dns/promises.js",
"lib/domain.js",
"lib/events.js",
"lib/fs.js",
"lib/fs/promises.js",
"lib/http.js",
"lib/http2.js",
"lib/https.js",
"lib/inspector.js",
"lib/inspector/promises.js",
"lib/internal/abort_controller.js",
"lib/internal/assert.js",
"lib/internal/assert/assertion_error.js",
"lib/internal/assert/calltracker.js",
"lib/internal/assert/myers_diff.js",
"lib/internal/assert/utils.js",
"lib/internal/async_context_frame.js",
"lib/internal/async_hooks.js",
"lib/internal/async_local_storage/async_context_frame.js",
"lib/internal/async_local_storage/async_hooks.js",
"lib/internal/blob.js",
"lib/internal/blocklist.js",
"lib/internal/bootstrap/node.js",
"lib/internal/bootstrap/realm.js",
"lib/internal/bootstrap/shadow_realm.js",
"lib/internal/bootstrap/switches/does_not_own_process_state.js",
"lib/internal/bootstrap/switches/does_own_process_state.js",
"lib/internal/bootstrap/switches/is_main_thread.js",
"lib/internal/bootstrap/switches/is_not_main_thread.js",
"lib/internal/bootstrap/web/exposed-wildcard.js",
"lib/internal/bootstrap/web/exposed-window-or-worker.js",
"lib/internal/buffer.js",
"lib/internal/child_process.js",
"lib/internal/child_process/serialization.js",
"lib/internal/cli_table.js",
"lib/internal/cluster/child.js",
"lib/internal/cluster/primary.js",
"lib/internal/cluster/round_robin_handle.js",
"lib/internal/cluster/shared_handle.js",
"lib/internal/cluster/utils.js",
"lib/internal/cluster/worker.js",
"lib/internal/console/constructor.js",
"lib/internal/console/global.js",
"lib/internal/constants.js",
"lib/internal/crypto/aes.js",
"lib/internal/crypto/certificate.js",
"lib/internal/crypto/cfrg.js",
"lib/internal/crypto/cipher.js",
"lib/internal/crypto/diffiehellman.js",
"lib/internal/crypto/ec.js",
"lib/internal/crypto/hash.js",
"lib/internal/crypto/hashnames.js",
"lib/internal/crypto/hkdf.js",
"lib/internal/crypto/keygen.js",
"lib/internal/crypto/keys.js",
"lib/internal/crypto/mac.js",
"lib/internal/crypto/pbkdf2.js",
"lib/internal/crypto/random.js",
"lib/internal/crypto/rsa.js",
"lib/internal/crypto/scrypt.js",
"lib/internal/crypto/sig.js",
"lib/internal/crypto/util.js",
"lib/internal/crypto/webcrypto.js",
"lib/internal/crypto/webidl.js",
"lib/internal/crypto/x509.js",
"lib/internal/data_url.js",
"lib/internal/debugger/inspect.js",
"lib/internal/debugger/inspect_client.js",
"lib/internal/debugger/inspect_repl.js",
"lib/internal/dgram.js",
"lib/internal/dns/callback_resolver.js",
"lib/internal/dns/promises.js",
"lib/internal/dns/utils.js",
"lib/internal/encoding.js",
"lib/internal/error_serdes.js",
"lib/internal/errors.js",
"lib/internal/errors/error_source.js",
"lib/internal/event_target.js",
"lib/internal/events/abort_listener.js",
"lib/internal/events/symbols.js",
"lib/internal/file.js",
"lib/internal/fixed_queue.js",
"lib/internal/freelist.js",
"lib/internal/freeze_intrinsics.js",
"lib/internal/fs/cp/cp-sync.js",
"lib/internal/fs/cp/cp.js",
"lib/internal/fs/dir.js",
"lib/internal/fs/glob.js",
"lib/internal/fs/promises.js",
"lib/internal/fs/read/context.js",
"lib/internal/fs/recursive_watch.js",
"lib/internal/fs/rimraf.js",
"lib/internal/fs/streams.js",
"lib/internal/fs/sync_write_stream.js",
"lib/internal/fs/utils.js",
"lib/internal/fs/watchers.js",
"lib/internal/heap_utils.js",
"lib/internal/histogram.js",
"lib/internal/http.js",
"lib/internal/http2/compat.js",
"lib/internal/http2/core.js",
"lib/internal/http2/util.js",
"lib/internal/inspector/network.js",
"lib/internal/inspector/network_http.js",
"lib/internal/inspector/network_http2.js",
"lib/internal/inspector/network_resources.js",
"lib/internal/inspector/network_undici.js",
"lib/internal/inspector_async_hook.js",
"lib/internal/inspector_network_tracking.js",
"lib/internal/js_stream_socket.js",
"lib/internal/legacy/processbinding.js",
"lib/internal/linkedlist.js",
"lib/internal/main/check_syntax.js",
"lib/internal/main/embedding.js",
"lib/internal/main/eval_stdin.js",
"lib/internal/main/eval_string.js",
"lib/internal/main/inspect.js",
"lib/internal/main/mksnapshot.js",
"lib/internal/main/print_help.js",
"lib/internal/main/prof_process.js",
"lib/internal/main/repl.js",
"lib/internal/main/run_main_module.js",
"lib/internal/main/test_runner.js",
"lib/internal/main/watch_mode.js",
"lib/internal/main/worker_thread.js",
"lib/internal/mime.js",
"lib/internal/modules/cjs/loader.js",
"lib/internal/modules/customization_hooks.js",
"lib/internal/modules/esm/assert.js",
"lib/internal/modules/esm/create_dynamic_module.js",
"lib/internal/modules/esm/formats.js",
"lib/internal/modules/esm/get_format.js",
"lib/internal/modules/esm/hooks.js",
"lib/internal/modules/esm/initialize_import_meta.js",
"lib/internal/modules/esm/load.js",
"lib/internal/modules/esm/loader.js",
"lib/internal/modules/esm/module_job.js",
"lib/internal/modules/esm/module_map.js",
"lib/internal/modules/esm/resolve.js",
"lib/internal/modules/esm/shared_constants.js",
"lib/internal/modules/esm/translators.js",
"lib/internal/modules/esm/utils.js",
"lib/internal/modules/esm/worker.js",
"lib/internal/modules/helpers.js",
"lib/internal/modules/package_json_reader.js",
"lib/internal/modules/run_main.js",
"lib/internal/modules/typescript.js",
"lib/internal/navigator.js",
"lib/internal/net.js",
"lib/internal/options.js",
"lib/internal/per_context/domexception.js",
"lib/internal/per_context/messageport.js",
"lib/internal/per_context/primordials.js",
"lib/internal/perf/event_loop_delay.js",
"lib/internal/perf/event_loop_utilization.js",
"lib/internal/perf/nodetiming.js",
"lib/internal/perf/observe.js",
"lib/internal/perf/performance.js",
"lib/internal/perf/performance_entry.js",
"lib/internal/perf/resource_timing.js",
"lib/internal/perf/timerify.js",
"lib/internal/perf/usertiming.js",
"lib/internal/perf/utils.js",
"lib/internal/priority_queue.js",
"lib/internal/process/execution.js",
"lib/internal/process/finalization.js",
"lib/internal/process/per_thread.js",
"lib/internal/process/permission.js",
"lib/internal/process/pre_execution.js",
"lib/internal/process/promises.js",
"lib/internal/process/report.js",
"lib/internal/process/signal.js",
"lib/internal/process/task_queues.js",
"lib/internal/process/warning.js",
"lib/internal/process/worker_thread_only.js",
"lib/internal/promise_hooks.js",
"lib/internal/querystring.js",
"lib/internal/quic/quic.js",
"lib/internal/quic/state.js",
"lib/internal/quic/stats.js",
"lib/internal/quic/symbols.js",
"lib/internal/readline/callbacks.js",
"lib/internal/readline/emitKeypressEvents.js",
"lib/internal/readline/interface.js",
"lib/internal/readline/promises.js",
"lib/internal/readline/utils.js",
"lib/internal/repl.js",
"lib/internal/repl/await.js",
"lib/internal/repl/history.js",
"lib/internal/repl/utils.js",
"lib/internal/socket_list.js",
"lib/internal/socketaddress.js",
"lib/internal/source_map/prepare_stack_trace.js",
"lib/internal/source_map/source_map.js",
"lib/internal/source_map/source_map_cache.js",
"lib/internal/source_map/source_map_cache_map.js",
"lib/internal/stream_base_commons.js",
"lib/internal/streams/add-abort-signal.js",
"lib/internal/streams/compose.js",
"lib/internal/streams/destroy.js",
"lib/internal/streams/duplex.js",
"lib/internal/streams/duplexify.js",
"lib/internal/streams/duplexpair.js",
"lib/internal/streams/end-of-stream.js",
"lib/internal/streams/from.js",
"lib/internal/streams/lazy_transform.js",
"lib/internal/streams/legacy.js",
"lib/internal/streams/operators.js",
"lib/internal/streams/passthrough.js",
"lib/internal/streams/pipeline.js",
"lib/internal/streams/readable.js",
"lib/internal/streams/state.js",
"lib/internal/streams/transform.js",
"lib/internal/streams/utils.js",
"lib/internal/streams/writable.js",
"lib/internal/test/binding.js",
"lib/internal/test/transfer.js",
"lib/internal/test_runner/assert.js",
"lib/internal/test_runner/coverage.js",
"lib/internal/test_runner/harness.js",
"lib/internal/test_runner/mock/loader.js",
"lib/internal/test_runner/mock/mock.js",
"lib/internal/test_runner/mock/mock_timers.js",
"lib/internal/test_runner/reporter/dot.js",
"lib/internal/test_runner/reporter/junit.js",
"lib/internal/test_runner/reporter/lcov.js",
"lib/internal/test_runner/reporter/spec.js",
"lib/internal/test_runner/reporter/tap.js",
"lib/internal/test_runner/reporter/utils.js",
"lib/internal/test_runner/reporter/v8-serializer.js",
"lib/internal/test_runner/runner.js",
"lib/internal/test_runner/snapshot.js",
"lib/internal/test_runner/test.js",
"lib/internal/test_runner/tests_stream.js",
"lib/internal/test_runner/utils.js",
"lib/internal/timers.js",
"lib/internal/tls/secure-context.js",
"lib/internal/tls/secure-pair.js",
"lib/internal/trace_events_async_hooks.js",
"lib/internal/tty.js",
"lib/internal/url.js",
"lib/internal/util.js",
"lib/internal/util/colors.js",
"lib/internal/util/comparisons.js",
"lib/internal/util/debuglog.js",
"lib/internal/util/diff.js",
"lib/internal/util/inspect.js",
"lib/internal/util/inspector.js",
"lib/internal/util/parse_args/parse_args.js",
"lib/internal/util/parse_args/utils.js",
"lib/internal/util/trace_sigint.js",
"lib/internal/util/types.js",
"lib/internal/v8/startup_snapshot.js",
"lib/internal/v8_prof_polyfill.js",
"lib/internal/v8_prof_processor.js",
"lib/internal/validators.js",
"lib/internal/vm.js",
"lib/internal/vm/module.js",
"lib/internal/wasm_web_api.js",
"lib/internal/watch_mode/files_watcher.js",
"lib/internal/watchdog.js",
"lib/internal/webidl.js",
"lib/internal/webstorage.js",
"lib/internal/webstreams/adapters.js",
"lib/internal/webstreams/compression.js",
"lib/internal/webstreams/encoding.js",
"lib/internal/webstreams/queuingstrategies.js",
"lib/internal/webstreams/readablestream.js",
"lib/internal/webstreams/transfer.js",
"lib/internal/webstreams/transformstream.js",
"lib/internal/webstreams/util.js",
"lib/internal/webstreams/writablestream.js",
"lib/internal/worker.js",
"lib/internal/worker/clone_dom_exception.js",
"lib/internal/worker/io.js",
"lib/internal/worker/js_transferable.js",
"lib/internal/worker/messaging.js",
"lib/module.js",
"lib/net.js",
"lib/os.js",
"lib/path.js",
"lib/path/posix.js",
"lib/path/win32.js",
"lib/perf_hooks.js",
"lib/process.js",
"lib/punycode.js",
"lib/querystring.js",
"lib/readline.js",
"lib/readline/promises.js",
"lib/repl.js",
"lib/sea.js",
"lib/sqlite.js",
"lib/stream.js",
"lib/stream/consumers.js",
"lib/stream/promises.js",
"lib/stream/web.js",
"lib/string_decoder.js",
"lib/sys.js",
"lib/test.js",
"lib/test/reporters.js",
"lib/timers.js",
"lib/timers/promises.js",
"lib/tls.js",
"lib/trace_events.js",
"lib/tty.js",
"lib/url.js",
"lib/util.js",
"lib/util/types.js",
"lib/v8.js",
"lib/vm.js",
"lib/wasi.js",
"lib/worker_threads.js",
"lib/zlib.js"
],
"node_module_version": 127,
"node_no_browser_globals": "false",
"node_prefix": "/usr/local",
"node_release_urlbase": "https://nodejs.org/download/release/",
"node_shared": "false",
"node_shared_ada": "false",
"node_shared_brotli": "false",
"node_shared_cares": "false",
"node_shared_http_parser": "false",
"node_shared_libuv": "false",
"node_shared_nghttp2": "false",
"node_shared_nghttp3": "false",
"node_shared_ngtcp2": "false",
"node_shared_openssl": "false",
"node_shared_simdjson": "false",
"node_shared_simdutf": "false",
"node_shared_sqlite": "false",
"node_shared_uvwasi": "false",
"node_shared_zlib": "false",
"node_shared_zstd": "false",
"node_tag": "",
"node_target_type": "executable",
"node_use_amaro": "true",
"node_use_bundled_v8": "true",
"node_use_node_code_cache": "true",
"node_use_node_snapshot": "true",
"node_use_openssl": "true",
"node_use_sqlite": "true",
"node_use_v8_platform": "true",
"node_with_ltcg": "false",
"node_without_node_options": "false",
"node_write_snapshot_as_array_literals": "false",
"openssl_is_fips": "false",
"openssl_quic": "false",
"ossfuzz": "false",
"shlib_suffix": "127.dylib",
"single_executable_application": "true",
"suppress_all_error_on_warn": "false",
"target_arch": "arm64",
"ubsan": 0,
"use_ccache_win": 0,
"use_prefix_to_find_headers": "false",
"v8_enable_31bit_smis_on_64bit_arch": 0,
"v8_enable_extensible_ro_snapshot": 0,
"v8_enable_external_code_space": 0,
"v8_enable_gdbjit": 0,
"v8_enable_hugepage": 0,
"v8_enable_i18n_support": 1,
"v8_enable_inspector": 1,
"v8_enable_javascript_promise_hooks": 1,
"v8_enable_lite_mode": 0,
"v8_enable_maglev": 0,
"v8_enable_object_print": 1,
"v8_enable_pointer_compression": 0,
"v8_enable_pointer_compression_shared_cage": 0,
"v8_enable_sandbox": 0,
"v8_enable_shared_ro_heap": 1,
"v8_enable_webassembly": 1,
"v8_optimized_debug": 1,
"v8_promise_internal_field_count": 1,
"v8_random_seed": 0,
"v8_trace_maps": 0,
"v8_use_siphash": 1,
"want_separate_host_toolset": 0,
"xcode_version": "16.0",
"nodedir": "/var/folders/6f/8vj8fvhd31n00w9ncbf9v4lc0000gn/T/prebuild/node/22.21.0",
"python": "/Applications/Xcode.app/Contents/Developer/usr/bin/python3",
"standalone_static_library": 1,
"target": "22.21.0",
"build_v8_with_gn": "false",
"global_prefix": "/usr/local",
"local_prefix": "/Users/bergautosoft/Documents/projects/fltr-app",
"globalconfig": "/usr/local/etc/npmrc",
"userconfig": "/Users/bergautosoft/.npmrc",
"init_module": "/Users/bergautosoft/.npm-init.js",
"npm_version": "10.9.4",
"node_gyp": "/usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js",
"cache": "/Users/bergautosoft/.npm",
"user_agent": "npm/10.9.4 node/v22.21.0 darwin arm64 workspaces/false",
"prefix": "/usr/local"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

3463
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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