ready to release

This commit is contained in:
DevOFVictory
2025-11-02 22:41:05 +01:00
parent 7ae75a7b27
commit 90d4ab2491
13 changed files with 515 additions and 125 deletions

View File

@@ -33,7 +33,7 @@
"image": "./assets/images/icon.png", "image": "./assets/images/icon.png",
"imageWidth": 300, "imageWidth": 300,
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#000456" "backgroundColor": "#000457"
} }
], ],
"expo-web-browser" "expo-web-browser"

View File

@@ -1,5 +1,4 @@
import { AutoCompleteItem } from "@/apis/autoCompleteApi"; import { AutoCompleteItem } from "@/apis/autoCompleteApi";
import { SearchResultItem } from "@/apis/searchApi";
import { Season } from "@/apis/seasonApi"; import { Season } from "@/apis/seasonApi";
import { Show } from "@/apis/showApi"; import { Show } from "@/apis/showApi";
import styles from "@/app/tabStyles/indexStyles"; import styles from "@/app/tabStyles/indexStyles";
@@ -8,33 +7,52 @@ import { useDiscoveryContext } from "@/contexts/DiscoveryContext";
import { FontAwesome } from "@expo/vector-icons"; import { FontAwesome } from "@expo/vector-icons";
import Feather from "@expo/vector-icons/Feather"; import Feather from "@expo/vector-icons/Feather";
import React from "react"; import React from "react";
import { Keyboard, ScrollView, Text, TextInput, TouchableOpacity, View } from "react-native"; import {
Keyboard,
ScrollView,
Text,
TextInput,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from "react-native";
import { useSearch } from "@/hooks/useSearch";
import { getShowById } from "@/apis/showApi"; import { getShowById } from "@/apis/showApi";
import PersonRow from "@/components/discovery/PersonRow"; import PersonRow from "@/components/discovery/PersonRow";
import SeasonCarousel from "@/components/discovery/SeasonCarousel"; import SeasonCarousel from "@/components/discovery/SeasonCarousel";
import TagChip from "@/components/discovery/TagChip"; import TagChip from "@/components/discovery/TagChip";
import { getIconName, mapApiPersonToUI, mapApiSeasonToUI, mapApiShowToUI } from "@/utils/searchMapping"; import { useSearch } from "@/hooks/useSearch";
import {
getIconName,
mapApiPersonToUI,
mapApiSeasonToUI,
mapApiShowToUI,
} from "@/utils/searchMapping";
export default function ExploreScreen() { export default function ExploreScreen() {
const { query, setQuery, suggestions } = useDiscoveryContext(); const { query, setQuery, suggestions } = useDiscoveryContext();
const [tags, setTags] = React.useState<AutoCompleteItem[]>([]); const [tags, setTags] = React.useState<AutoCompleteItem[]>([]);
const tagStrings = React.useMemo(() => tags.map((t) => t.text), [tags]); const tagStrings = React.useMemo(() => tags.map((t) => t.text), [tags]);
const { data: results = [] } = useSearch(tagStrings);
// Show metadata cache by id (filled from SHOW results and lazy-loaded by id) 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>>({}); const [showsById, setShowsById] = React.useState<Record<number, Show>>({});
// --- helpers --- // Steuerung für Vorschlagsliste
const [showSuggestions, setShowSuggestions] = React.useState(false);
function tagAdded(tag: AutoCompleteItem) { function tagAdded(tag: AutoCompleteItem) {
const nextTags = tags.some((t) => t.text === tag.text) ? tags : [...tags, tag]; const nextTags = tags.some((t) => t.text === tag.text) ? tags : [...tags, tag];
setTags(nextTags); setTags(nextTags);
setQuery(""); setQuery("");
setShowSuggestions(false);
Keyboard.dismiss(); Keyboard.dismiss();
} }
@@ -43,7 +61,7 @@ export default function ExploreScreen() {
setTags(nextTags); setTags(nextTags);
} }
// Keep our local show cache in sync with SHOW items returned by search // Cache mit SHOW-Resultaten füllen
React.useEffect(() => { React.useEffect(() => {
const fromResults: Record<number, Show> = {}; const fromResults: Record<number, Show> = {};
for (const r of results) { for (const r of results) {
@@ -57,9 +75,7 @@ export default function ExploreScreen() {
} }
}, [results]); }, [results]);
// SEASON-Ergebnisse nach showId gruppieren
// Group SEASON results by showId
const seasonsByShowId = React.useMemo(() => { const seasonsByShowId = React.useMemo(() => {
const map = new Map<number, Season[]>(); const map = new Map<number, Season[]>();
for (const r of results) { for (const r of results) {
@@ -71,7 +87,6 @@ export default function ExploreScreen() {
list.push(s as Season); list.push(s as Season);
map.set(key, list); map.set(key, list);
} }
// sort seasons per show by startDate asc
for (const [k, list] of map) { for (const [k, list] of map) {
list.sort((a, b) => { list.sort((a, b) => {
const da = a?.startDate ? new Date(a.startDate).getTime() : Number.POSITIVE_INFINITY; const da = a?.startDate ? new Date(a.startDate).getTime() : Number.POSITIVE_INFINITY;
@@ -83,7 +98,7 @@ export default function ExploreScreen() {
return map; return map;
}, [results]); }, [results]);
// Lazy fetch missing shows needed for Season carousels // Fehlende Shows für Carousels nachladen
React.useEffect(() => { React.useEffect(() => {
const needed = Array.from(seasonsByShowId.keys()).filter((id) => !showsById[id]); const needed = Array.from(seasonsByShowId.keys()).filter((id) => !showsById[id]);
if (needed.length === 0) return; if (needed.length === 0) return;
@@ -97,7 +112,7 @@ export default function ExploreScreen() {
const next: Record<number, Show> = {}; const next: Record<number, Show> = {};
for (const s of fetched) { for (const s of fetched) {
if (!s) continue; if (!s) continue;
next[s.id] = s; // wichtig: s.id, nicht s.showId next[s.id] = s;
} }
if (Object.keys(next).length) { if (Object.keys(next).length) {
setShowsById((prev) => ({ ...prev, ...next })); setShowsById((prev) => ({ ...prev, ...next }));
@@ -107,10 +122,12 @@ export default function ExploreScreen() {
} }
})(); })();
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, [seasonsByShowId, showsById]); }, [seasonsByShowId, showsById]);
// PERSON hits shown at the top (like old screen) // PERSON-Resultate
const persons = React.useMemo(() => { const persons = React.useMemo(() => {
return results return results
.filter((r) => r.type === "PERSON") .filter((r) => r.type === "PERSON")
@@ -118,22 +135,97 @@ export default function ExploreScreen() {
.sort((a, b) => (a.name || "").localeCompare(b.name || "")); .sort((a, b) => (a.name || "").localeCompare(b.name || ""));
}, [results]); }, [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 ( return (
<View style={[styles.mainContainer]}> <View style={[styles.mainContainer]}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.title, { fontSize: 28 }]}>Durchsuchen</Text> <Text style={[styles.title, { fontSize: 28 }]}>Durchsuchen</Text>
</View> </View>
<TouchableWithoutFeedback
onPress={() => {
Keyboard.dismiss();
setShowSuggestions(false);
}}
>
<View style={{ flex: 1 }}>
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
{/* Search bar */}
<View style={styles.searchContainer}> <View style={styles.searchContainer}>
<TextInput <TextInput
value={query} value={query}
onChangeText={setQuery} 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?" placeholder="Wonach suchst du?"
placeholderTextColor="" placeholderTextColor=""
style={styles.searchInput} style={styles.searchInput}
returnKeyType="search" returnKeyType="search"
onFocus={() => setShowSuggestions(true)}
onSubmitEditing={() => { onSubmitEditing={() => {
if (!query.trim()) return; if (!query.trim()) return;
tagAdded({ type: "CUSTOM", text: query.trim() }); tagAdded({ type: "CUSTOM", text: query.trim() });
@@ -144,11 +236,18 @@ export default function ExploreScreen() {
{query.length === 0 ? ( {query.length === 0 ? (
<Feather name="search" size={24} color="hsl(221, 39%, 80%)" /> <Feather name="search" size={24} color="hsl(221, 39%, 80%)" />
) : ( ) : (
<Feather name="x" size={24} color="hsl(221, 39%, 80%)" onPress={() => setQuery("")} /> <Feather
name="x"
size={24}
color="hsl(221, 39%, 80%)"
onPress={() => {
setQuery("");
setShowSuggestions(false);
}}
/>
)} )}
</View> </View>
{/* Tag chips */}
<View style={styles.tagContainer}> <View style={styles.tagContainer}>
{tags.map((tag) => ( {tags.map((tag) => (
<TagChip <TagChip
@@ -157,14 +256,13 @@ export default function ExploreScreen() {
label={tag.text} label={tag.text}
onPress={() => { onPress={() => {
tagRemoved(tag); tagRemoved(tag);
}} }}
/> />
))} ))}
</View> </View>
{/* Suggestions dropdown */} {/* Suggestions dropdown */}
{query.length > 0 && ( {query.length > 0 && showSuggestions && (
<View style={styles.suggestionsSection}> <View style={styles.suggestionsSection}>
<Text style={styles.suggestionTitle}>Suchvorschläge</Text> <Text style={styles.suggestionTitle}>Suchvorschläge</Text>
<ScrollView keyboardShouldPersistTaps="handled"> <ScrollView keyboardShouldPersistTaps="handled">
@@ -174,8 +272,14 @@ export default function ExploreScreen() {
style={styles.suggestionContainer} style={styles.suggestionContainer}
onPress={() => tagAdded(suggestion)} onPress={() => tagAdded(suggestion)}
> >
<FontAwesome name={getIconName(suggestion.type)} size={16} color="hsl(0, 0%, 90%)" /> <FontAwesome
<Text style={styles.suggestionLabel}>{suggestion.text}</Text> name={getIconName(suggestion.type)}
size={16}
color="hsl(0, 0%, 90%)"
/>
<Text style={styles.suggestionLabel}>
{suggestion.text}
</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</ScrollView> </ScrollView>
@@ -196,21 +300,46 @@ export default function ExploreScreen() {
</View> </View>
)} )}
{/* Staffeln grouped by Show with page view */}
<View style={styles.sectionContainer}> <View style={styles.sectionContainer}>
{seasonsByShowId.size > 0 && (
<Text style={styles.sectionTitle}>Staffeln</Text> <Text style={styles.sectionTitle}>Staffeln</Text>
)}
{Array.from(seasonsByShowId.entries()).map(([showId, seasons]) => { {Array.from(seasonsByShowId.entries()).map(([showId, seasons]) => {
const show = showsById[Number(showId)]; const show = showsById[Number(showId)];
if (!seasons || seasons.length === 0) return null; if (!seasons || seasons.length === 0) return null;
// If show metadata is not yet loaded, render a minimal ShowBox fallback once per page item
if (!show) { if (!show) {
return ( return (
<SeasonCarousel <SeasonCarousel
key={`sc-${showId}`} key={`sc-${showId}`}
show={{ showId: showId as any, title: "blaaa", description: "", genre: "", thumbnailUrl: "", running: false } as any} show={
{
showId: showId as any,
title: "blaaa",
description: "",
genre: "",
thumbnailUrl: "",
running: false,
} as any
}
seasons={seasons} seasons={seasons}
renderItem={(s) => <ShowBox show={{ showId: showId as any, title: `Show #${showId}`, description: "", genre: "", thumbnailUrl: "", running: false } as any} displayedSeason={s} shadow={false} />} renderItem={(s) => (
<ShowBox
show={
{
showId: showId as any,
title: `Show #${showId}`,
description: "",
genre: "",
thumbnailUrl: "",
running: false,
} as any
}
displayedSeason={s}
shadow={false}
/>
)}
/> />
); );
} }
@@ -219,19 +348,96 @@ export default function ExploreScreen() {
key={`sc-${showId}`} key={`sc-${showId}`}
show={show} show={show}
seasons={seasons} seasons={seasons}
renderItem={(s) => <ShowBox show={show} displayedSeason={s} shadow={false} />} renderItem={(s) => (
<ShowBox show={show} displayedSeason={s} shadow={false} />
)}
/> />
); );
})} })}
{seasonsByShowId.size === 0 && ( {/* Schöner Empty-State */}
<Text style={styles.centerText}> {noResults && (
Keine Staffeln gefunden. Passe deine Tags an. <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>
<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> </View>
</ScrollView> </ScrollView>
</View> </View>
</View> </View>
</TouchableWithoutFeedback>
</View>
); );
} }

View File

@@ -2,6 +2,7 @@ import styles from "@/app/tabStyles/indexStyles";
import ShowCard from "@/components/ui/ShowCard"; import ShowCard from "@/components/ui/ShowCard";
import { useShows } from "@/hooks/useShows"; import { useShows } from "@/hooks/useShows";
import { useStreamingServices } from "@/hooks/useStreamingServices"; import { useStreamingServices } from "@/hooks/useStreamingServices";
import Feather from "@expo/vector-icons/Feather";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { router } from "expo-router"; import { router } from "expo-router";
import React from "react"; import React from "react";
@@ -89,6 +90,28 @@ export default function HomeScreen() {
<GestureHandlerRootView> <GestureHandlerRootView>
<View style={styles.mainContainer}> <View style={styles.mainContainer}>
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity
onPress={() => {
router.push("/legal");
}}
style={{
position: "absolute",
left: 16,
top: "63%",
transform: [{ translateY: -12 }],
height: 40,
width: 40,
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "rgba(255,255,255,0.06)",
}}
accessibilityRole="button"
accessibilityLabel="Menü öffnen"
>
<Feather name="menu" size={22} color="#FFFFFF" />
</TouchableOpacity>
<Text style={styles.title}>FLTR</Text> <Text style={styles.title}>FLTR</Text>
</View> </View>
<ScrollView <ScrollView

View File

@@ -23,7 +23,14 @@ export default function RootLayout() {
headerShown: false, headerShown: false,
}} }}
/> />
<Stack.Screen
name="legal"
options={{
presentation: "modal",
headerShown: false
}} />
</Stack> </Stack>
</DiscoveryProvider> </DiscoveryProvider>
</QueryClientProvider> </QueryClientProvider>
); );

154
app/legal.tsx Normal file
View File

@@ -0,0 +1,154 @@
import Feather from "@expo/vector-icons/Feather";
import { router } from "expo-router";
import React from "react";
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
export default function LegalScreen() {
return (
<View style={{ flex: 1, backgroundColor: "hsl(220, 15%, 10%)" }}>
<View
style={{
paddingTop: 18,
paddingHorizontal: 16,
paddingBottom: 6,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text
style={{
color: "white",
fontSize: 20,
fontWeight: "700",
letterSpacing: 0.3,
}}
>
Info
</Text>
<TouchableOpacity
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel="Modal schließen"
style={{
height: 40,
width: 40,
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
backgroundColor: "rgba(255,255,255,0.08)",
}}
>
<Feather name="x" size={22} color="#FFFFFF" />
</TouchableOpacity>
</View>
<ScrollView
contentContainerStyle={{
paddingHorizontal: 16,
paddingBottom: 28,
}}
showsVerticalScrollIndicator={false}
>
{/* Impressum Card */}
<View
style={{
backgroundColor: "rgba(255,255,255,0.05)",
borderRadius: 14,
padding: 16,
gap: 10,
marginTop: 8,
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "700" }}>
Impressum
</Text>
<View style={{ gap: 4 }}>
<Text style={styles.mono}>Berg Autosoft</Text>
<Text style={styles.mono}>Joe Felipe Berg</Text>
<Text style={styles.mono}>Stöckener Straße 35</Text>
<Text style={styles.mono}>30419 Hannover</Text>
</View>
<View style={{ height: 8 }} />
<View style={{ gap: 4 }}>
<Text style={styles.dim}>+49 1522 5642948</Text>
<Text style={styles.dim}>kontakt@berg-autosoft.de</Text>
</View>
<View style={{ height: 8 }} />
<View style={{ gap: 4 }}>
<Text style={styles.dim}>Steuernummer: 25/103/17193</Text>
<Text style={styles.dim}>USt-ID: DE361689728</Text>
</View>
</View>
{/* Support Card */}
<View
style={{
backgroundColor: "rgba(255,255,255,0.05)",
borderRadius: 14,
padding: 16,
gap: 10,
marginTop: 12,
}}
>
<Text style={{ color: "white", fontSize: 18, fontWeight: "700" }}>
Support
</Text>
<Text style={styles.body}>
Sollten Sie Probleme bei der Nutzung der iOS- oder Android-App FLTR
haben, wenden Sie sich bitte direkt an den Support.
</Text>
<View style={{ height: 6 }} />
<Text style={styles.body}>Schreiben Sie eine E-Mail an:</Text>
<Text style={[styles.mono, { fontSize: 15 }]}>
developer@berg-autosoft.de
</Text>
<Text style={[styles.dim, { marginTop: 10 }]}>
Wir bemühen uns, Ihr Anliegen so schnell wie möglich zu bearbeiten.
</Text>
</View>
{/* Footer */}
<View style={{ alignItems: "center", marginTop: 18 }}>
<Text
style={{
color: "rgba(255,255,255,0.6)",
fontSize: 12,
letterSpacing: 0.2,
}}
>
© 2025 Berg Autosoft
</Text>
</View>
</ScrollView>
</View>
);
}
const styles = {
mono: {
color: "rgba(255,255,255,0.92)",
fontSize: 16,
} as const,
dim: {
color: "rgba(255,255,255,0.75)",
fontSize: 14,
} as const,
body: {
color: "rgba(255,255,255,0.88)",
fontSize: 14,
lineHeight: 20,
} as const,
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB