modified: files to ios26 ui/ux

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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