1016 lines
27 KiB
TypeScript
1016 lines
27 KiB
TypeScript
import { ThemedText } from "@/components/themed-text";
|
|
import { ThemedView } from "@/components/themed-view";
|
|
import { IconSymbol } from "@/components/ui/icon-symbol";
|
|
import { Colors } from "@/constants/theme";
|
|
import { useAppState } from "@/context/AppStateContext";
|
|
import { useTheme } from "@/context/ThemeContext";
|
|
import { fetchSearch } from "@/lib/api";
|
|
import { getInitials, getLogoGradient } from "@/lib/avatar-utils";
|
|
import { SearchLeague, SearchPlayer, SearchTeam } from "@/types/api";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { BlurView } from "expo-blur";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import { Stack, useRouter } from "expo-router";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ActivityIndicator,
|
|
Animated,
|
|
FlatList,
|
|
ScrollView,
|
|
StyleSheet,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
View
|
|
} from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
|
|
type SearchType = "all" | "team" | "player" | "league";
|
|
|
|
interface SearchEntity {
|
|
id: string;
|
|
type: SearchType;
|
|
name: string;
|
|
meta: {
|
|
country?: string;
|
|
league?: string;
|
|
season?: string;
|
|
team?: string;
|
|
pos?: string;
|
|
};
|
|
}
|
|
|
|
// 将 API 数据转换为 SearchEntity
|
|
function convertLeagueToEntity(league: SearchLeague): SearchEntity {
|
|
return {
|
|
id: `league_${league.ID}`,
|
|
type: "league",
|
|
name: league.name,
|
|
meta: {
|
|
country: league.countryName,
|
|
season: "", // API 中没有 season 字段
|
|
},
|
|
};
|
|
}
|
|
|
|
function convertPlayerToEntity(player: SearchPlayer): SearchEntity {
|
|
return {
|
|
id: `player_${player.ID}`,
|
|
type: "player",
|
|
name: player.name,
|
|
meta: {
|
|
country: player.countryName,
|
|
team: player.teamName,
|
|
league: player.leagueName,
|
|
pos: player.position,
|
|
},
|
|
};
|
|
}
|
|
|
|
function convertTeamToEntity(team: SearchTeam): SearchEntity {
|
|
return {
|
|
id: `team_${team.ID}`,
|
|
type: "team",
|
|
name: team.name,
|
|
meta: {
|
|
country: team.countryName,
|
|
league: team.leagueName,
|
|
},
|
|
};
|
|
}
|
|
|
|
|
|
// 获取副标题文本
|
|
function getSubText(entity: SearchEntity): string {
|
|
const { meta } = entity;
|
|
const parts: string[] = [];
|
|
|
|
if (entity.type === "league") {
|
|
// 联赛:显示国家
|
|
if (meta.country) parts.push(meta.country);
|
|
} else if (entity.type === "player") {
|
|
// 球员:优先显示球队和位置,如果没有位置则显示联赛
|
|
if (meta.team) parts.push(meta.team);
|
|
if (meta.pos) {
|
|
parts.push(meta.pos);
|
|
} else if (meta.league) {
|
|
parts.push(meta.league);
|
|
}
|
|
} else if (entity.type === "team") {
|
|
// 球队:显示国家和联赛
|
|
if (meta.country) parts.push(meta.country);
|
|
if (meta.league) parts.push(meta.league);
|
|
}
|
|
|
|
return parts.filter(Boolean).join(" · ") || "";
|
|
}
|
|
|
|
const RECENT_SEARCHES_KEY = "recent_searches";
|
|
|
|
export default function SearchScreen() {
|
|
const router = useRouter();
|
|
const { theme } = useTheme();
|
|
const { t } = useTranslation();
|
|
const insets = useSafeAreaInsets();
|
|
const isDark = theme === "dark";
|
|
const textColor = isDark ? "#FFFFFF" : "#000000";
|
|
const cardBg = isDark ? "#1C1C1E" : "#FFFFFF";
|
|
const borderColor = isDark ? "#2C2C2E" : "rgba(0,0,0,0.1)";
|
|
const pageBg = isDark ? Colors.dark.background : "#f2f2f7";
|
|
const { state } = useAppState();
|
|
|
|
const [searchType, setSearchType] = useState<SearchType>("all");
|
|
const [query, setQuery] = useState("");
|
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
|
const [showDetailSheet, setShowDetailSheet] = useState(false);
|
|
const [selectedEntity, setSelectedEntity] = useState<SearchEntity | null>(null);
|
|
const [entities, setEntities] = useState<SearchEntity[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hasSearched, setHasSearched] = useState(false);
|
|
|
|
const sheetAnim = useRef(new Animated.Value(0)).current;
|
|
const searchTimeoutRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
const loadRecentSearches = async () => {
|
|
try {
|
|
const stored = await AsyncStorage.getItem(RECENT_SEARCHES_KEY);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (Array.isArray(parsed)) {
|
|
setRecentSearches(parsed);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load recent searches:", err);
|
|
}
|
|
};
|
|
loadRecentSearches();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (showDetailSheet) {
|
|
Animated.spring(sheetAnim, {
|
|
toValue: 1,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
} else {
|
|
Animated.timing(sheetAnim, {
|
|
toValue: 0,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
}
|
|
}, [showDetailSheet]);
|
|
|
|
// 搜索 API 调用(带防抖)
|
|
useEffect(() => {
|
|
if (searchTimeoutRef.current) {
|
|
clearTimeout(searchTimeoutRef.current);
|
|
}
|
|
|
|
if (!query.trim()) {
|
|
setEntities([]);
|
|
setError(null);
|
|
setHasSearched(false);
|
|
return;
|
|
}
|
|
|
|
searchTimeoutRef.current = setTimeout(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const result = await fetchSearch(
|
|
query.trim(),
|
|
state.selectedSportId || undefined
|
|
);
|
|
|
|
const allEntities: SearchEntity[] = [
|
|
...result.leagues.map(convertLeagueToEntity),
|
|
...result.players.map(convertPlayerToEntity),
|
|
...result.teams.map(convertTeamToEntity),
|
|
];
|
|
|
|
setEntities(allEntities);
|
|
setHasSearched(true);
|
|
if (allEntities.length > 0) {
|
|
const searchQuery = query.trim();
|
|
setRecentSearches((prev) => {
|
|
const filtered = prev.filter((x) => x !== searchQuery);
|
|
const newRecent = [searchQuery, ...filtered].slice(0, 8);
|
|
AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecent)).catch(
|
|
(err) => console.error("Failed to save recent searches:", err)
|
|
);
|
|
return newRecent;
|
|
});
|
|
}
|
|
} catch (err: any) {
|
|
console.error("Search error:", err);
|
|
setError(err?.message || t("search.error"));
|
|
setEntities([]);
|
|
setHasSearched(true);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, 500); // 500ms 防抖
|
|
|
|
return () => {
|
|
if (searchTimeoutRef.current) {
|
|
clearTimeout(searchTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [query, state.selectedSportId]);
|
|
|
|
const filteredEntities = entities.filter((e) => {
|
|
if (searchType === "all") return true;
|
|
if (e.type !== searchType) return false;
|
|
return true;
|
|
});
|
|
|
|
const handleSearchTypeChange = (type: SearchType) => {
|
|
setSearchType(type);
|
|
// 不再清空输入框,保持搜索结果
|
|
};
|
|
|
|
const handleRecentClick = (text: string) => {
|
|
setQuery(text);
|
|
};
|
|
|
|
const handleEntityPress = (entity: SearchEntity) => {
|
|
setSelectedEntity(entity);
|
|
setShowDetailSheet(true);
|
|
};
|
|
|
|
const handleClearRecent = () => {
|
|
setRecentSearches([]);
|
|
AsyncStorage.removeItem(RECENT_SEARCHES_KEY).catch(
|
|
(err) => console.error("Failed to clear recent searches:", err)
|
|
);
|
|
};
|
|
|
|
const handleSaveToRecent = () => {
|
|
if (selectedEntity) {
|
|
const newRecent = [
|
|
selectedEntity.name,
|
|
...recentSearches.filter((x) => x !== selectedEntity.name),
|
|
].slice(0, 8);
|
|
setRecentSearches(newRecent);
|
|
AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecent)).catch(
|
|
(err) => console.error("Failed to save recent searches:", err)
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleOpenDetail = () => {
|
|
if (selectedEntity) {
|
|
handleSaveToRecent();
|
|
// TODO: Navigate to detail page
|
|
setShowDetailSheet(false);
|
|
}
|
|
};
|
|
|
|
const renderSearchBar = () => (
|
|
<View style={styles.searchWrap}>
|
|
<BlurView
|
|
intensity={80}
|
|
tint={isDark ? "dark" : "light"}
|
|
style={[
|
|
styles.search,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
>
|
|
<IconSymbol name="search" size={18} color={isDark ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.6)"} />
|
|
<TextInput
|
|
style={[styles.searchInput, { color: isDark ? "#fff" : "#000" }]}
|
|
placeholder={t("search.placeholder")}
|
|
placeholderTextColor={isDark ? "rgba(255,255,255,0.4)" : "rgba(0,0,0,0.4)"}
|
|
value={query}
|
|
onChangeText={setQuery}
|
|
/>
|
|
{query.length > 0 && (
|
|
<TouchableOpacity onPress={() => setQuery("")}>
|
|
<IconSymbol name="close-circle" size={18} color={isDark ? "rgba(255,255,255,0.68)" : "rgba(0,0,0,0.5)"} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</BlurView>
|
|
</View>
|
|
);
|
|
|
|
const renderChips = () => (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={styles.chips}
|
|
contentContainerStyle={styles.chipsContent}
|
|
>
|
|
{(["all", "team", "player", "league"] as SearchType[]).map((type) => (
|
|
<TouchableOpacity
|
|
key={type}
|
|
style={[
|
|
styles.chip,
|
|
searchType === type && styles.chipActive,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
onPress={() => handleSearchTypeChange(type)}
|
|
>
|
|
<ThemedText
|
|
style={[
|
|
styles.chipText,
|
|
searchType === type && styles.chipTextActive,
|
|
]}
|
|
>
|
|
{t(`search.type.${type}`)}
|
|
</ThemedText>
|
|
</TouchableOpacity>
|
|
))}
|
|
</ScrollView>
|
|
);
|
|
|
|
const renderRecentSearches = () => {
|
|
if (recentSearches.length === 0 || query.trim()) return null;
|
|
|
|
return (
|
|
<>
|
|
<View style={styles.sectionHeader}>
|
|
<ThemedText style={styles.sectionTitle}>
|
|
{t("search.recent")}
|
|
</ThemedText>
|
|
<TouchableOpacity onPress={handleClearRecent}>
|
|
<ThemedText style={styles.sectionAction}>
|
|
{t("search.clear")}
|
|
</ThemedText>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={styles.recentRow}
|
|
contentContainerStyle={styles.recentRowContent}
|
|
>
|
|
{recentSearches.map((item, index) => (
|
|
<TouchableOpacity
|
|
key={index}
|
|
style={[
|
|
styles.recentCard,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
onPress={() => handleRecentClick(item)}
|
|
>
|
|
<ThemedText style={styles.recentCardTitle} numberOfLines={1}>
|
|
{item}
|
|
</ThemedText>
|
|
<ThemedText style={styles.recentCardSub} numberOfLines={1}>
|
|
{t(`search.type.${searchType}`)}
|
|
</ThemedText>
|
|
</TouchableOpacity>
|
|
))}
|
|
</ScrollView>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderEntityItem = ({ item }: { item: SearchEntity }) => {
|
|
const gradient = getLogoGradient(item.name);
|
|
const initials = getInitials(item.name);
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.entityRow,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
onPress={() => handleEntityPress(item)}
|
|
>
|
|
<LinearGradient
|
|
colors={[gradient.color1, gradient.color2]}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={[
|
|
styles.logoDot,
|
|
{
|
|
borderColor: isDark
|
|
? "rgba(255, 255, 255, 0.14)"
|
|
: "rgba(255, 255, 255, 0.3)",
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText style={styles.logoText}>{initials}</ThemedText>
|
|
</LinearGradient>
|
|
<View style={styles.entityMeta}>
|
|
<ThemedText style={styles.entityName} numberOfLines={1}>
|
|
{item.name}
|
|
</ThemedText>
|
|
<ThemedText style={styles.entitySub} numberOfLines={1}>
|
|
{getSubText(item)}
|
|
</ThemedText>
|
|
</View>
|
|
<ThemedText style={styles.entityBadge}>
|
|
{t(`search.type.${item.type}`)}
|
|
</ThemedText>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
const renderEmptyState = () => (
|
|
<View
|
|
style={[
|
|
styles.emptyState,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText style={styles.emptyTitle}>
|
|
{t("search.no_results")}
|
|
</ThemedText>
|
|
<ThemedText style={styles.emptySub}>
|
|
{t("search.no_results_hint")}
|
|
</ThemedText>
|
|
</View>
|
|
);
|
|
|
|
const renderDetailSheet = () => {
|
|
if (!selectedEntity) return null;
|
|
|
|
const translateY = sheetAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [600, 0],
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<TouchableOpacity
|
|
style={styles.overlay}
|
|
activeOpacity={1}
|
|
onPress={() => setShowDetailSheet(false)}
|
|
>
|
|
<BlurView
|
|
intensity={20}
|
|
tint="dark"
|
|
style={StyleSheet.absoluteFill}
|
|
/>
|
|
</TouchableOpacity>
|
|
<Animated.View
|
|
style={[
|
|
styles.sheet,
|
|
{
|
|
transform: [{ translateY }],
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
{ paddingBottom: insets.bottom + 14 },
|
|
]}
|
|
>
|
|
<View style={styles.sheetHeader}>
|
|
<View style={styles.sheetTitleContainer}>
|
|
<ThemedText style={styles.sheetTitle}>
|
|
{selectedEntity.name}
|
|
</ThemedText>
|
|
<ThemedText style={styles.sheetSub}>
|
|
{t(`search.type.${selectedEntity.type}`)} ·{" "}
|
|
{getSubText(selectedEntity)}
|
|
</ThemedText>
|
|
</View>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.sheetBtn,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
onPress={() => setShowDetailSheet(false)}
|
|
>
|
|
<IconSymbol
|
|
name="close"
|
|
size={18}
|
|
color={isDark ? "rgba(255,255,255,0.9)" : "rgba(0,0,0,0.9)"}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={styles.kvContainer}>
|
|
{selectedEntity.type === "team" && (
|
|
<>
|
|
{selectedEntity.meta.country && (
|
|
<View
|
|
style={[
|
|
styles.kvItem,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText style={styles.kvLabel}>
|
|
{t("search.detail.country")}
|
|
</ThemedText>
|
|
<ThemedText style={styles.kvValue} numberOfLines={1}>
|
|
{selectedEntity.meta.country}
|
|
</ThemedText>
|
|
</View>
|
|
)}
|
|
{selectedEntity.meta.league && (
|
|
<View
|
|
style={[
|
|
styles.kvItem,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText style={styles.kvLabel}>
|
|
{t("search.detail.league")}
|
|
</ThemedText>
|
|
<ThemedText style={styles.kvValue} numberOfLines={1}>
|
|
{selectedEntity.meta.league}
|
|
</ThemedText>
|
|
</View>
|
|
)}
|
|
</>
|
|
)}
|
|
{selectedEntity.type === "player" && (
|
|
<>
|
|
{selectedEntity.meta.team && (
|
|
<View
|
|
style={[
|
|
styles.kvItem,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText style={styles.kvLabel}>
|
|
{t("search.detail.team")}
|
|
</ThemedText>
|
|
<ThemedText style={styles.kvValue} numberOfLines={1}>
|
|
{selectedEntity.meta.team}
|
|
</ThemedText>
|
|
</View>
|
|
)}
|
|
{selectedEntity.meta.pos && (
|
|
<View
|
|
style={[
|
|
styles.kvItem,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText style={styles.kvLabel}>
|
|
{t("search.detail.position")}
|
|
</ThemedText>
|
|
<ThemedText style={styles.kvValue} numberOfLines={1}>
|
|
{selectedEntity.meta.pos}
|
|
</ThemedText>
|
|
</View>
|
|
)}
|
|
{selectedEntity.meta.league && (
|
|
<View
|
|
style={[
|
|
styles.kvItem,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText style={styles.kvLabel}>
|
|
{t("search.detail.league")}
|
|
</ThemedText>
|
|
<ThemedText style={styles.kvValue} numberOfLines={1}>
|
|
{selectedEntity.meta.league}
|
|
</ThemedText>
|
|
</View>
|
|
)}
|
|
{selectedEntity.meta.country && (
|
|
<View
|
|
style={[
|
|
styles.kvItem,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText style={styles.kvLabel}>
|
|
{t("search.detail.country")}
|
|
</ThemedText>
|
|
<ThemedText style={styles.kvValue} numberOfLines={1}>
|
|
{selectedEntity.meta.country}
|
|
</ThemedText>
|
|
</View>
|
|
)}
|
|
</>
|
|
)}
|
|
{selectedEntity.type === "league" && (
|
|
<>
|
|
{selectedEntity.meta.country && (
|
|
<View
|
|
style={[
|
|
styles.kvItem,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
>
|
|
<ThemedText style={styles.kvLabel}>
|
|
{t("search.detail.country")}
|
|
</ThemedText>
|
|
<ThemedText style={styles.kvValue} numberOfLines={1}>
|
|
{selectedEntity.meta.country}
|
|
</ThemedText>
|
|
</View>
|
|
)}
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.sheetActions}>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.sheetBtn,
|
|
{
|
|
backgroundColor: cardBg,
|
|
borderColor: borderColor,
|
|
},
|
|
]}
|
|
onPress={handleOpenDetail}
|
|
>
|
|
<ThemedText style={styles.sheetBtnText}>
|
|
{t("search.detail.open")}
|
|
</ThemedText>
|
|
</TouchableOpacity>
|
|
{/* <TouchableOpacity
|
|
style={[
|
|
styles.sheetBtn,
|
|
styles.sheetBtnPrimary,
|
|
{
|
|
backgroundColor: isDark
|
|
? "rgba(255, 210, 90, 0.12)"
|
|
: "rgba(255, 210, 90, 0.2)",
|
|
borderColor: isDark
|
|
? "rgba(255, 210, 90, 0.28)"
|
|
: "rgba(255, 210, 90, 0.4)",
|
|
},
|
|
]}
|
|
onPress={handleSaveToRecent}
|
|
>
|
|
<ThemedText
|
|
style={[
|
|
styles.sheetBtnText,
|
|
styles.sheetBtnTextPrimary,
|
|
]}
|
|
>
|
|
{t("search.detail.save")}
|
|
</ThemedText>
|
|
</TouchableOpacity> */}
|
|
</View>
|
|
</Animated.View>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<ThemedView style={[styles.container, { backgroundColor: pageBg }]}>
|
|
<Stack.Screen
|
|
options={{
|
|
title: t("search.title"),
|
|
headerShown: true,
|
|
headerBackTitle: t("settings.back"),
|
|
// Ensure header matches theme to avoid white flash
|
|
headerStyle: {
|
|
backgroundColor: pageBg,
|
|
},
|
|
headerTintColor: textColor,
|
|
headerShadowVisible: false,
|
|
// Present the screen as a normal card and slide from right
|
|
presentation: "card",
|
|
animation: "slide_from_right",
|
|
// Set the scene/content background to match theme during transition
|
|
contentStyle: {
|
|
backgroundColor: pageBg,
|
|
},
|
|
}}
|
|
/>
|
|
|
|
<View style={styles.content}>
|
|
<View style={styles.contentContainer}>
|
|
{renderSearchBar()}
|
|
{renderChips()}
|
|
{renderRecentSearches()}
|
|
|
|
{query.trim() && (
|
|
<View style={styles.sectionHeader}>
|
|
<ThemedText style={styles.sectionTitle}>
|
|
{t("search.results")}
|
|
{searchType !== "all" && ` · ${t(`search.type.${searchType}`)}`}
|
|
</ThemedText>
|
|
<ThemedText style={styles.sectionAction}>
|
|
{loading
|
|
? t("search.loading")
|
|
: error
|
|
? t("search.error")
|
|
: `${filteredEntities.length} ${t("search.found")}`}
|
|
</ThemedText>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{query.trim() ? (
|
|
loading ? (
|
|
<ThemedView style={styles.center}>
|
|
<ActivityIndicator size="large" color={Colors[theme].tint} />
|
|
</ThemedView>
|
|
) : error ? (
|
|
<View style={styles.emptyState}>
|
|
<ThemedText style={styles.emptyTitle}>{error}</ThemedText>
|
|
<ThemedText style={styles.emptySub}>
|
|
{t("search.error_hint")}
|
|
</ThemedText>
|
|
</View>
|
|
) : hasSearched && filteredEntities.length === 0 ? (
|
|
renderEmptyState()
|
|
) : (
|
|
<FlatList
|
|
data={filteredEntities}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={renderEntityItem}
|
|
contentContainerStyle={[
|
|
styles.listContent,
|
|
{ paddingBottom: insets.bottom + 20 },
|
|
]}
|
|
showsVerticalScrollIndicator={false}
|
|
/>
|
|
)
|
|
) : null}
|
|
</View>
|
|
|
|
{showDetailSheet && renderDetailSheet()}
|
|
</ThemedView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
},
|
|
contentContainer: {
|
|
paddingHorizontal: 12,
|
|
paddingTop: 8,
|
|
},
|
|
listContent: {
|
|
paddingHorizontal: 12,
|
|
},
|
|
searchWrap: {
|
|
marginBottom: 10,
|
|
},
|
|
search: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
height: 46,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 18,
|
|
borderWidth: 1,
|
|
overflow: "hidden",
|
|
},
|
|
searchInput: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
fontSize: 15,
|
|
},
|
|
chips: {
|
|
marginBottom: 8,
|
|
},
|
|
chipsContent: {
|
|
gap: 10,
|
|
paddingBottom: 2,
|
|
},
|
|
chip: {
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 6,
|
|
borderRadius: 999,
|
|
borderWidth: 1,
|
|
},
|
|
chipActive: {},
|
|
chipText: {
|
|
fontSize: 12,
|
|
fontWeight: "700",
|
|
opacity: 0.8,
|
|
},
|
|
chipTextActive: {
|
|
opacity: 1,
|
|
},
|
|
sectionHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginTop: 12,
|
|
marginBottom: 8,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: 12,
|
|
opacity: 0.8,
|
|
fontWeight: "700",
|
|
},
|
|
sectionAction: {
|
|
fontSize: 12,
|
|
opacity: 0.5,
|
|
fontWeight: "600",
|
|
},
|
|
recentRow: {
|
|
marginBottom: 8,
|
|
},
|
|
recentRowContent: {
|
|
gap: 10,
|
|
paddingBottom: 2,
|
|
},
|
|
recentCard: {
|
|
minWidth: 190,
|
|
padding: 12,
|
|
borderRadius: 16,
|
|
borderWidth: 1,
|
|
},
|
|
recentCardTitle: {
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
},
|
|
recentCardSub: {
|
|
fontSize: 12,
|
|
opacity: 0.5,
|
|
marginTop: 4,
|
|
fontWeight: "500",
|
|
},
|
|
entityRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
padding: 12,
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
marginBottom: 10,
|
|
},
|
|
logoDot: {
|
|
width: 34,
|
|
height: 34,
|
|
borderRadius: 8,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderWidth: 1,
|
|
},
|
|
logoText: {
|
|
fontSize: 12,
|
|
fontWeight: "700",
|
|
color: "rgba(255, 255, 255, 0.92)",
|
|
},
|
|
entityMeta: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
entityName: {
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
},
|
|
entitySub: {
|
|
fontSize: 12,
|
|
opacity: 0.5,
|
|
marginTop: 3,
|
|
fontWeight: "500",
|
|
},
|
|
entityBadge: {
|
|
fontSize: 10,
|
|
opacity: 0.9,
|
|
fontWeight: "700",
|
|
},
|
|
center: {
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
padding: 20,
|
|
},
|
|
emptyState: {
|
|
padding: 14,
|
|
borderRadius: 20,
|
|
borderWidth: 1,
|
|
},
|
|
emptyTitle: {
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
},
|
|
emptySub: {
|
|
marginTop: 6,
|
|
opacity: 0.5,
|
|
fontSize: 12,
|
|
fontWeight: "500",
|
|
lineHeight: 18,
|
|
},
|
|
overlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
zIndex: 80,
|
|
},
|
|
sheet: {
|
|
position: "absolute",
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
borderTopLeftRadius: 22,
|
|
borderTopRightRadius: 22,
|
|
borderWidth: 1,
|
|
padding: 12,
|
|
zIndex: 90,
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: -10 },
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 30,
|
|
elevation: 20,
|
|
},
|
|
sheetHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: 10,
|
|
marginBottom: 12,
|
|
},
|
|
sheetTitleContainer: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
sheetTitle: {
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
},
|
|
sheetSub: {
|
|
fontSize: 12,
|
|
opacity: 0.5,
|
|
fontWeight: "500",
|
|
marginTop: 2,
|
|
},
|
|
kvContainer: {
|
|
flexDirection: "row",
|
|
flexWrap: "wrap",
|
|
gap: 10,
|
|
marginBottom: 12,
|
|
},
|
|
kvItem: {
|
|
flex: 1,
|
|
minWidth: "45%",
|
|
padding: 12,
|
|
borderRadius: 18,
|
|
borderWidth: 1,
|
|
},
|
|
kvLabel: {
|
|
fontSize: 12,
|
|
opacity: 0.5,
|
|
fontWeight: "700",
|
|
},
|
|
kvValue: {
|
|
marginTop: 6,
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
},
|
|
sheetActions: {
|
|
flexDirection: "row",
|
|
gap: 10,
|
|
},
|
|
sheetBtn: {
|
|
flex: 1,
|
|
height: 36,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 14,
|
|
borderWidth: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
sheetBtnPrimary: {},
|
|
sheetBtnText: {
|
|
fontSize: 12,
|
|
fontWeight: "700",
|
|
opacity: 0.9,
|
|
},
|
|
sheetBtnTextPrimary: {
|
|
opacity: 1,
|
|
},
|
|
});
|