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

@@ -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",
},
});