diff --git a/app/_layout.tsx b/app/_layout.tsx index db80d31..93f869b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -68,6 +68,13 @@ function RootLayoutNav() { headerShown: false, }} /> + diff --git a/app/search.tsx b/app/search.tsx new file mode 100644 index 0000000..c64916e --- /dev/null +++ b/app/search.tsx @@ -0,0 +1,1086 @@ +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { useTheme } from "@/context/ThemeContext"; +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 { + Animated, + FlatList, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + View +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +type SearchType = "team" | "player" | "league"; + +interface SearchEntity { + id: string; + type: SearchType; + name: string; + meta: { + country?: string; + league?: string; + season?: string; + team?: string; + pos?: string; + }; +} + +// 模拟数据 +const ENTITIES: SearchEntity[] = [ + { id: "t_rm", type: "team", name: "Real Madrid", meta: { country: "Spain", league: "LaLiga", season: "2025/26" } }, + { id: "t_fcb", type: "team", name: "FC Barcelona", meta: { country: "Spain", league: "LaLiga", season: "2025/26" } }, + { id: "t_mci", type: "team", name: "Manchester City", meta: { country: "England", league: "Premier League", season: "2025/26" } }, + { id: "t_liv", type: "team", name: "Liverpool", meta: { country: "England", league: "Premier League", season: "2025/26" } }, + { id: "t_ars", type: "team", name: "Arsenal", meta: { country: "England", league: "Premier League", season: "2025/26" } }, + { id: "p_mbappe", type: "player", name: "Kylian Mbappé", meta: { country: "France", team: "Real Madrid", league: "LaLiga", pos: "FW", season: "2025/26" } }, + { id: "p_haaland", type: "player", name: "Erling Haaland", meta: { country: "Norway", team: "Manchester City", league: "Premier League", pos: "FW", season: "2025/26" } }, + { id: "p_salah", type: "player", name: "Mohamed Salah", meta: { country: "Egypt", team: "Liverpool", league: "Premier League", pos: "FW", season: "2025/26" } }, + { id: "p_curry", type: "player", name: "Stephen Curry", meta: { country: "USA", team: "Golden State Warriors", league: "NBA", pos: "G", season: "2025/26" } }, + { id: "l_epl", type: "league", name: "Premier League", meta: { country: "England", season: "2025/26" } }, + { id: "l_laliga", type: "league", name: "LaLiga", meta: { country: "Spain", season: "2025/26" } }, + { id: "l_ucl", type: "league", name: "UEFA Champions League", meta: { country: "Europe", season: "2025/26" } }, + { id: "l_nba", type: "league", name: "NBA", meta: { country: "USA", season: "2025/26" } }, +]; + +// 生成首字母 +function getInitials(name: string): string { + const s = name.trim(); + if (!s) return "SN"; + const clean = s.replace(/[^a-zA-Z0-9]+/g, " ").trim(); + const parts = clean ? clean.split(/\s+/) : [s]; + const a = (parts[0] || s).charAt(0) || "S"; + const b = (parts[1] || "").charAt(0) || ((parts[0] || s).charAt(1) || "N"); + return (a + b).toUpperCase(); +} + +// 生成颜色哈希 +function hashString(str: string): number { + let hash = 2166136261; + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = (hash * 16777619) >>> 0; + } + return hash >>> 0; +} + +// HSL 转 RGB +function hslToRgb(h: number, s: number, l: number): string { + s /= 100; + l /= 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r = 0, g = 0, b = 0; + + if (0 <= h && h < 60) { + r = c; g = x; b = 0; + } else if (60 <= h && h < 120) { + r = x; g = c; b = 0; + } else if (120 <= h && h < 180) { + r = 0; g = c; b = x; + } else if (180 <= h && h < 240) { + r = 0; g = x; b = c; + } else if (240 <= h && h < 300) { + r = x; g = 0; b = c; + } else if (300 <= h && h < 360) { + r = c; g = 0; b = x; + } + r = Math.round((r + m) * 255); + g = Math.round((g + m) * 255); + b = Math.round((b + m) * 255); + return `rgb(${r}, ${g}, ${b})`; +} + +// 生成渐变颜色 +function getLogoGradient(name: string): { color1: string; color2: string } { + const h = hashString(name); + const hue = h % 360; + const hue2 = (hue + 36 + (h % 24)) % 360; + return { + color1: hslToRgb(hue, 85, 58), + color2: hslToRgb(hue2, 85, 48), + }; +} + +// 获取副标题文本 +function getSubText(entity: SearchEntity): string { + const { meta } = entity; + if (entity.type === "league") { + return `${meta.country || ""} · ${meta.season || ""}`; + } + if (entity.type === "player") { + return `${meta.team || ""} · ${meta.league || ""}`; + } + return `${meta.country || ""} · ${meta.league || ""}`; +} + +export default function SearchScreen() { + const router = useRouter(); + const { theme } = useTheme(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const isDark = theme === "dark"; + + const [searchType, setSearchType] = useState("team"); + const [query, setQuery] = useState(""); + const [recentSearches, setRecentSearches] = useState([ + "Real Madrid", + "Premier League", + "Curry", + ]); + const [showDetailSheet, setShowDetailSheet] = useState(false); + const [selectedEntity, setSelectedEntity] = useState(null); + + const sheetAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (showDetailSheet) { + Animated.spring(sheetAnim, { + toValue: 1, + useNativeDriver: true, + }).start(); + } else { + Animated.timing(sheetAnim, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }).start(); + } + }, [showDetailSheet]); + + const filteredEntities = ENTITIES.filter((e) => { + if (e.type !== searchType) return false; + if (!query.trim()) return true; + const q = query.toLowerCase(); + return ( + e.name.toLowerCase().includes(q) || getSubText(e).toLowerCase().includes(q) + ); + }); + + const handleSearchTypeChange = (type: SearchType) => { + setSearchType(type); + setQuery(""); + }; + + const handleRecentClick = (text: string) => { + setQuery(text); + }; + + const handleEntityPress = (entity: SearchEntity) => { + setSelectedEntity(entity); + setShowDetailSheet(true); + }; + + const handleClearRecent = () => { + setRecentSearches([]); + }; + + const handleSaveToRecent = () => { + if (selectedEntity) { + const newRecent = [ + selectedEntity.name, + ...recentSearches.filter((x) => x !== selectedEntity.name), + ].slice(0, 10); + setRecentSearches(newRecent); + } + }; + + const handleOpenDetail = () => { + if (selectedEntity) { + handleSaveToRecent(); + // TODO: Navigate to detail page + setShowDetailSheet(false); + } + }; + + const renderSearchBar = () => ( + + + + + {query.length > 0 && ( + setQuery("")}> + + + )} + + + ); + + const renderChips = () => ( + + {(["team", "player", "league"] as SearchType[]).map((type) => ( + handleSearchTypeChange(type)} + > + + {t(`search.type.${type}`)} + + + ))} + + ); + + const renderRecentSearches = () => { + if (recentSearches.length === 0 || query.trim()) return null; + + return ( + <> + + + {t("search.recent")} + + + + {t("search.clear")} + + + + + {recentSearches.map((item, index) => ( + handleRecentClick(item)} + > + + {item} + + + {t(`search.type.${searchType}`)} + + + ))} + + + ); + }; + + const renderEntityItem = ({ item }: { item: SearchEntity }) => { + const gradient = getLogoGradient(item.name); + const initials = getInitials(item.name); + + return ( + handleEntityPress(item)} + > + + {initials} + + + + {item.name} + + + {getSubText(item)} + + + + {t(`search.type.${item.type}`)} + + + ); + }; + + const renderEmptyState = () => ( + + + {t("search.no_results")} + + + {t("search.no_results_hint")} + + + ); + + const renderDetailSheet = () => { + if (!selectedEntity) return null; + + const translateY = sheetAnim.interpolate({ + inputRange: [0, 1], + outputRange: [600, 0], + }); + + return ( + <> + setShowDetailSheet(false)} + > + + + + + + + {selectedEntity.name} + + + {t(`search.type.${selectedEntity.type}`)} ·{" "} + {getSubText(selectedEntity)} + + + setShowDetailSheet(false)} + > + + + + + + {selectedEntity.type === "team" && ( + <> + + + {t("search.detail.country")} + + + {selectedEntity.meta.country || "-"} + + + + + {t("search.detail.league")} + + + {selectedEntity.meta.league || "-"} + + + + + {t("search.detail.season")} + + + {selectedEntity.meta.season || "-"} + + + + )} + {selectedEntity.type === "player" && ( + <> + + + {t("search.detail.team")} + + + {selectedEntity.meta.team || "-"} + + + + + {t("search.detail.league")} + + + {selectedEntity.meta.league || "-"} + + + + + {t("search.detail.position")} + + + {selectedEntity.meta.pos || "-"} + + + + + {t("search.detail.country")} + + + {selectedEntity.meta.country || "-"} + + + + )} + {selectedEntity.type === "league" && ( + <> + + + {t("search.detail.country")} + + + {selectedEntity.meta.country || "-"} + + + + + {t("search.detail.season")} + + + {selectedEntity.meta.season || "-"} + + + + )} + + + + + + {t("search.detail.open")} + + + + + {t("search.detail.save")} + + + + + + ); + }; + + return ( + + + + + router.back()} + > + + + + {t("search.title")} + + {t("search.subtitle")} + + + + + + + + + + {renderSearchBar()} + {renderChips()} + {renderRecentSearches()} + + + + {t("search.results")} · {t(`search.type.${searchType}`)} + + + {query.trim() + ? `${filteredEntities.length} ${t("search.found")}` + : t("search.tap_to_open")} + + + + + item.id} + renderItem={renderEntityItem} + ListEmptyComponent={() => renderEmptyState()} + contentContainerStyle={[ + styles.listContent, + { paddingBottom: insets.bottom + 20 }, + ]} + showsVerticalScrollIndicator={false} + /> + + + {showDetailSheet && renderDetailSheet()} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + nav: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingHorizontal: 12, + paddingBottom: 12, + position: "sticky", + top: 0, + zIndex: 40, + }, + iconBtn: { + width: 38, + height: 38, + borderRadius: 14, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + }, + brand: { + flex: 1, + minWidth: 0, + gap: 2, + }, + brandTitle: { + fontSize: 18, + fontWeight: "900", + letterSpacing: 0.2, + }, + brandSub: { + fontSize: 12, + opacity: 0.42, + fontWeight: "800", + }, + 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: 8, + borderRadius: 999, + borderWidth: 1, + }, + chipActive: {}, + chipText: { + fontSize: 14, + fontWeight: "900", + opacity: 0.78, + }, + chipTextActive: { + opacity: 1, + }, + sectionHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginTop: 12, + marginBottom: 8, + }, + sectionTitle: { + fontSize: 13, + opacity: 0.55, + fontWeight: "900", + }, + sectionAction: { + fontSize: 12, + opacity: 0.42, + fontWeight: "800", + }, + recentRow: { + marginBottom: 8, + }, + recentRowContent: { + gap: 10, + paddingBottom: 2, + }, + recentCard: { + minWidth: 190, + padding: 12, + borderRadius: 16, + borderWidth: 1, + }, + recentCardTitle: { + fontWeight: "900", + }, + recentCardSub: { + fontSize: 12, + opacity: 0.55, + marginTop: 4, + }, + entityRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + padding: 12, + borderRadius: 18, + borderWidth: 1, + marginBottom: 10, + }, + logoDot: { + width: 34, + height: 34, + borderRadius: 14, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + }, + logoText: { + fontSize: 12, + fontWeight: "900", + color: "rgba(255, 255, 255, 0.92)", + }, + entityMeta: { + flex: 1, + minWidth: 0, + }, + entityName: { + fontWeight: "900", + }, + entitySub: { + fontSize: 12, + opacity: 0.55, + marginTop: 3, + }, + entityBadge: { + fontSize: 12, + opacity: 0.52, + fontWeight: "900", + }, + emptyState: { + padding: 14, + borderRadius: 20, + borderWidth: 1, + }, + emptyTitle: { + fontWeight: "900", + }, + emptySub: { + marginTop: 6, + opacity: 0.6, + fontSize: 12, + fontWeight: "800", + 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: { + fontWeight: "900", + }, + sheetSub: { + fontSize: 12, + opacity: 0.52, + fontWeight: "800", + 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.52, + fontWeight: "900", + }, + kvValue: { + marginTop: 6, + fontWeight: "900", + }, + sheetActions: { + flexDirection: "row", + gap: 10, + }, + sheetBtn: { + flex: 1, + height: 36, + paddingHorizontal: 12, + borderRadius: 14, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + }, + sheetBtnPrimary: {}, + sheetBtnText: { + fontSize: 14, + fontWeight: "900", + opacity: 0.86, + }, + sheetBtnTextPrimary: { + opacity: 1, + }, +}); diff --git a/components/home-header.tsx b/components/home-header.tsx index 406144b..c7079e8 100644 --- a/components/home-header.tsx +++ b/components/home-header.tsx @@ -25,9 +25,7 @@ export function HomeHeader() { { - /* TODO: Search Action */ - }} + onPress={() => router.push("/search")} > diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 98cdde6..0ded263 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -69,5 +69,31 @@ "badminton": "Badminton", "snooker": "Snooker", "volleyball": "Volleyball" + }, + "search": { + "title": "Search", + "subtitle": "Teams · Players · Leagues", + "placeholder": "Search teams, players, leagues", + "recent": "Recent searches", + "clear": "Clear", + "results": "Results", + "found": "found", + "tap_to_open": "Tap to open", + "no_results": "No results", + "no_results_hint": "Try another keyword or switch category", + "type": { + "team": "Team", + "player": "Player", + "league": "League" + }, + "detail": { + "country": "Country", + "league": "League", + "season": "Season", + "team": "Team", + "position": "Position", + "open": "Open detail", + "save": "Save to recent" + } } } \ No newline at end of file diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index dfb1b5a..a7b2a68 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -69,5 +69,31 @@ "badminton": "羽毛球", "snooker": "斯诺克", "volleyball": "排球" + }, + "search": { + "title": "搜索", + "subtitle": "球队 · 球员 · 联赛", + "placeholder": "搜索球队、球员、联赛", + "recent": "最近搜索", + "clear": "清除", + "results": "结果", + "found": "个结果", + "tap_to_open": "点击打开", + "no_results": "无结果", + "no_results_hint": "尝试其他关键词或切换分类", + "type": { + "team": "球队", + "player": "球员", + "league": "联赛" + }, + "detail": { + "country": "国家", + "league": "联赛", + "season": "赛季", + "team": "球队", + "position": "位置", + "open": "打开详情", + "save": "保存到最近" + } } } \ No newline at end of file