modified: files to ios26 ui/ux
This commit is contained in:
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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user