modified: files to ios26 ui/ux
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
34
app/(tabs)/explore/_layout.tsx
Normal file
34
app/(tabs)/explore/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
579
app/(tabs)/explore/index.tsx
Normal file
579
app/(tabs)/explore/index.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
30
app/(tabs)/home/_layout.tsx
Normal file
30
app/(tabs)/home/_layout.tsx
Normal 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
353
app/(tabs)/home/index.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user