From 025b48309929825654641a57ab54a05f919513a8 Mon Sep 17 00:00:00 2001 From: yuchenglong Date: Wed, 14 Jan 2026 18:15:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=9B=B4=E6=92=AD=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 217 +++++- app/(tabs)/live.tsx | 360 ++-------- app/_layout.tsx | 12 +- app/live-detail/[id].tsx | 156 ++++ components/live-detail/events-timeline.tsx | 534 ++++++++++++++ components/live-detail/live-league-info.tsx | 90 +++ components/live-detail/live-match-tabs.tsx | 113 +++ components/live-detail/live-score-header.tsx | 201 ++++++ components/live-detail/odds-card.tsx | 122 ++++ components/live-detail/other-info-card.tsx | 81 +++ components/live-detail/stats-card.tsx | 704 +++++++++++++++++++ components/match-card.tsx | 9 +- constants/api.ts | 1 + context/AppStateContext.tsx | 67 ++ i18n/locales/en.json | 26 +- i18n/locales/zh.json | 26 +- lib/api.ts | 56 +- types/api.ts | 69 ++ 18 files changed, 2497 insertions(+), 347 deletions(-) create mode 100644 app/live-detail/[id].tsx create mode 100644 components/live-detail/events-timeline.tsx create mode 100644 components/live-detail/live-league-info.tsx create mode 100644 components/live-detail/live-match-tabs.tsx create mode 100644 components/live-detail/live-score-header.tsx create mode 100644 components/live-detail/odds-card.tsx create mode 100644 components/live-detail/other-info-card.tsx create mode 100644 components/live-detail/stats-card.tsx create mode 100644 context/AppStateContext.tsx diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 57220ad..939b46c 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -7,6 +7,7 @@ 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 { fetchLeagues, fetchSports, fetchTodayMatches } from "@/lib/api"; import { League, Match, Sport } from "@/types/api"; @@ -27,6 +28,9 @@ export default function HomeScreen() { const iconColor = isDark ? Colors.dark.icon : Colors.light.icon; const filterBg = isDark ? "#2C2C2E" : "#F2F2F7"; + const { state, updateSportId, updateDate, updateLeagueKey, updateTimezone } = + useAppState(); + const [sports, setSports] = useState([]); const [leagues, setLeagues] = useState([]); const [matches, setMatches] = useState([]); @@ -36,18 +40,25 @@ export default function HomeScreen() { const deviceTimeZone = useMemo(() => { try { - console.log("deviceTimeZone", Intl.DateTimeFormat().resolvedOptions().timeZone); + console.log( + "deviceTimeZone", + Intl.DateTimeFormat().resolvedOptions().timeZone + ); return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } }, []); - // Selection States - // 默认足球 - const [selectedSportId, setSelectedSportId] = useState(1); - const [selectedDate, setSelectedDate] = useState(new Date()); - const [selectedLeagueKey, setSelectedLeagueKey] = useState(null); + // 初始化时同步设备时区到 Context + useEffect(() => { + updateTimezone(deviceTimeZone); + }, [deviceTimeZone]); + + // Selection States - 从 Context 读取 + const selectedSportId = state.selectedSportId; + const selectedDate = state.selectedDate; + const selectedLeagueKey = state.selectedLeagueKey; const [filterMode, setFilterMode] = useState<"time" | "league">("time"); // 时间或联赛模式 // Modal Visibilities @@ -103,29 +114,93 @@ export default function HomeScreen() { const apiList = await fetchSports(); // 创建8个运动的完整列表 const defaultSports: Sport[] = [ - { id: 1, name: "football", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 2, name: "basketball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 3, name: "tennis", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 4, name: "cricket", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 5, name: "baseball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 6, name: "badminton", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 7, name: "snooker", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 8, name: "volleyball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, + { + id: 1, + name: "football", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 2, + name: "basketball", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 3, + name: "tennis", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 4, + name: "cricket", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 5, + name: "baseball", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 6, + name: "badminton", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 7, + name: "snooker", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 8, + name: "volleyball", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, ]; - + // 合并API返回的运动和默认列表 const sportsMap = new Map(); apiList.forEach((sport) => { sportsMap.set(sport.id, sport); }); - + // 补充默认运动到8个 defaultSports.forEach((sport) => { if (!sportsMap.has(sport.id)) { sportsMap.set(sport.id, sport); } }); - + const allSports = Array.from(sportsMap.values()) .sort((a, b) => a.id - b.id) .slice(0, 8); @@ -135,17 +210,80 @@ export default function HomeScreen() { console.error(e); // API失败时使用默认8个运动 const defaultSports: Sport[] = [ - { id: 1, name: "football", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 2, name: "basketball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 3, name: "tennis", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 4, name: "cricket", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 5, name: "baseball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 6, name: "badminton", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 7, name: "snooker", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 8, name: "volleyball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, + { + id: 1, + name: "football", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 2, + name: "basketball", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 3, + name: "tennis", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 4, + name: "cricket", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 5, + name: "baseball", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 6, + name: "badminton", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 7, + name: "snooker", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, + { + id: 8, + name: "volleyball", + description: "", + icon: "", + isActive: true, + updatedAt: "", + createdAt: "", + }, ]; setSports(defaultSports); - setSelectedSportId(1); } }; @@ -167,7 +305,11 @@ export default function HomeScreen() { const loadMatches = async (sportId: number) => { setLoading(true); try { - const list = await fetchTodayMatches(sportId, selectedDate, deviceTimeZone); + const list = await fetchTodayMatches( + sportId, + selectedDate, + deviceTimeZone + ); setMatches(list); } catch (e) { console.error(e); @@ -177,7 +319,7 @@ export default function HomeScreen() { }; const currentSport = sports.find((s) => s.id === selectedSportId); - + // 获取当前运动的国际化名称 const getSportName = (sport: Sport | undefined): string => { if (!sport) return t("home.select_sport"); @@ -196,7 +338,7 @@ export default function HomeScreen() { }; const handleLeagueSelect = (leagueKey: string) => { - setSelectedLeagueKey(leagueKey); + updateLeagueKey(leagueKey); console.log("Selected league:", leagueKey); }; @@ -230,10 +372,10 @@ export default function HomeScreen() { style={[styles.filterBtn, { backgroundColor: filterBg }]} onPress={() => setFilterMode(filterMode === "time" ? "league" : "time")} > - {filterMode === "time" ? t("home.time") : t("home.league")} @@ -285,8 +427,9 @@ export default function HomeScreen() { > - {selectedLeagueKey - ? leagues.find(l => l.key === selectedLeagueKey)?.name || t("home.select_league") + {selectedLeagueKey + ? leagues.find((l) => l.key === selectedLeagueKey)?.name || + t("home.select_league") : t("home.select_league")} @@ -327,7 +470,7 @@ export default function HomeScreen() { title={t("home.select_sport")} options={sportOptions} selectedValue={selectedSportId} - onSelect={setSelectedSportId} + onSelect={updateSportId} /> )} @@ -336,7 +479,7 @@ export default function HomeScreen() { visible={showCalendarModal} onClose={() => setShowCalendarModal(false)} selectedDate={selectedDate} - onSelectDate={setSelectedDate} + onSelectDate={updateDate} /> )} diff --git a/app/(tabs)/live.tsx b/app/(tabs)/live.tsx index 100774e..2ea4f5a 100644 --- a/app/(tabs)/live.tsx +++ b/app/(tabs)/live.tsx @@ -1,245 +1,84 @@ import { HomeHeader } from "@/components/home-header"; -import { LeagueModal } from "@/components/league-modal"; import { MatchCard } from "@/components/match-card"; -import { SelectionModal } from "@/components/selection-modal"; -import { CalendarModal } from "@/components/simple-calendar"; 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 { fetchLeagues, fetchSports, fetchTodayMatches } from "@/lib/api"; -import { League, Match, Sport } from "@/types/api"; -import React, { useEffect, useMemo, useState } from "react"; +import { fetchLiveScore } from "@/lib/api"; +import { LiveScoreMatch, Match } from "@/types/api"; +import { useRouter } from "expo-router"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - ActivityIndicator, - FlatList, - StyleSheet, - TouchableOpacity, - View, -} from "react-native"; +import { ActivityIndicator, FlatList, StyleSheet, View } from "react-native"; -export default function HomeScreen() { +export default function LiveScreen() { + const router = useRouter(); const { theme } = useTheme(); + const { t } = useTranslation(); const isDark = theme === "dark"; - const iconColor = isDark ? Colors.dark.icon : Colors.light.icon; - const filterBg = isDark ? "#2C2C2E" : "#F2F2F7"; + const { state } = useAppState(); - const [sports, setSports] = useState([]); - const [leagues, setLeagues] = useState([]); const [matches, setMatches] = useState([]); const [loading, setLoading] = useState(true); - const deviceTimeZone = useMemo(() => { - try { - return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - } catch { - return "UTC"; - } - }, []); - - // Selection States - // 默认足球 - const [selectedSportId, setSelectedSportId] = useState(1); - const [selectedDate, setSelectedDate] = useState(new Date()); - const [selectedLeagueKey, setSelectedLeagueKey] = useState(null); - const [filterMode, setFilterMode] = useState<"time" | "league">("time"); // 时间或联赛模式 - - // Modal Visibilities - const [showSportModal, setShowSportModal] = useState(false); - const [showCalendarModal, setShowCalendarModal] = useState(false); - const [showLeagueModal, setShowLeagueModal] = useState(false); - - // Load Sports and Leagues useEffect(() => { - loadSports(); - loadLeagues(); - }, []); + loadLiveMatches(); + // 每30秒自动刷新一次实时比分 + const interval = setInterval(loadLiveMatches, 30000); + return () => clearInterval(interval); + }, [state.selectedSportId, state.selectedLeagueKey, state.timezone]); - // Load Matches when sport or date changes - useEffect(() => { - if (selectedSportId !== null) { - loadMatches(selectedSportId); + const loadLiveMatches = async () => { + if (!state.selectedSportId) { + setLoading(false); + return; } - }, [selectedSportId, selectedDate]); - // Load Leagues when sport changes - useEffect(() => { - if (selectedSportId !== null) { - loadLeagues(); - } - }, [selectedSportId]); - - const loadSports = async () => { - try { - const apiList = await fetchSports(); - // 创建8个运动的完整列表 - const defaultSports: Sport[] = [ - { id: 1, name: "football", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 2, name: "basketball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 3, name: "tennis", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 4, name: "cricket", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 5, name: "baseball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 6, name: "badminton", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 7, name: "snooker", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 8, name: "volleyball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - ]; - - // 合并API返回的运动和默认列表 - const sportsMap = new Map(); - apiList.forEach((sport) => { - sportsMap.set(sport.id, sport); - }); - - // 补充默认运动到8个 - defaultSports.forEach((sport) => { - if (!sportsMap.has(sport.id)) { - sportsMap.set(sport.id, sport); - } - }); - - const allSports = Array.from(sportsMap.values()) - .sort((a, b) => a.id - b.id) - .slice(0, 8); - - setSports(allSports); - } catch (e) { - console.error(e); - // API失败时使用默认8个运动 - const defaultSports: Sport[] = [ - { id: 1, name: "football", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 2, name: "basketball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 3, name: "tennis", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 4, name: "cricket", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 5, name: "baseball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 6, name: "badminton", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 7, name: "snooker", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - { id: 8, name: "volleyball", description: "", icon: "", isActive: true, updatedAt: "", createdAt: "" }, - ]; - setSports(defaultSports); - setSelectedSportId(1); - } - }; - - // 加载联赛 - const loadLeagues = async () => { - try { - if (selectedSportId !== null) { - const list = await fetchLeagues(selectedSportId, ""); - setLeagues(list); - } - } catch (e) { - console.error(e); - } - }; - - const loadMatches = async (sportId: number) => { setLoading(true); try { - // Pass selectedDate if API supported it - const list = await fetchTodayMatches(sportId, undefined, deviceTimeZone); - setMatches(list); - } catch (e) { - console.error(e); + const liveData = await fetchLiveScore( + state.selectedSportId, + state.selectedLeagueKey ? parseInt(state.selectedLeagueKey) : undefined, + state.timezone + ); + + // 检查返回的数据是否为空或无效 + if (!liveData || !Array.isArray(liveData)) { + console.warn("LiveScore returned invalid data:", liveData); + setMatches([]); + return; + } + + // 将 LiveScoreMatch 转换为 Match 格式 + const converted: Match[] = liveData.map((item: LiveScoreMatch) => ({ + id: item.event_key.toString(), + league: item.league_name, + time: item.event_time, + home: item.event_home_team, + away: item.event_away_team, + meta: item.event_status, + scoreText: item.event_final_result || "0 - 0", + fav: false, + leagueId: item.league_key, + sportId: state.selectedSportId ?? undefined, + isLive: true, + })); + + setMatches(converted); + } catch (error) { + console.error("Load live matches error:", error); + setMatches([]); } finally { setLoading(false); } }; - const currentSport = sports.find((s) => s.id === selectedSportId); - - // 获取当前运动的国际化名称 - const getSportName = (sport: Sport | undefined): string => { - if (!sport) return t("home.select_sport"); - const sportKeyMap: { [key: number]: string } = { - 1: "football", - 2: "basketball", - 3: "tennis", - 4: "cricket", - 5: "baseball", - 6: "badminton", - 7: "snooker", - 8: "volleyball", - }; - const sportKey = sportKeyMap[sport.id] || sport.name.toLowerCase(); - return t(`sports.${sportKey}`, { defaultValue: sport.name }); - }; - - const handleLeagueSelect = (leagueKey: string) => { - setSelectedLeagueKey(leagueKey); - console.log("Selected league:", leagueKey); - }; - - const renderHeader = () => ( - - {/* Time/League Filter Toggle */} - setFilterMode(filterMode === "time" ? "league" : "time")} - > - - - {filterMode === "time" ? t("home.time") : t("home.league")} - - - - {/* Sport Selector */} - setShowSportModal(true)} - > - - - {getSportName(currentSport)} - - - - {/* Date/League Selector */} - {filterMode === "time" ? ( - setShowCalendarModal(true)} - > - - {selectedDate.getDate()} - - - {selectedDate.getHours()}: - {selectedDate.getMinutes().toString().padStart(2, "0")} - - - ) : ( - setShowLeagueModal(true)} - > - - - {selectedLeagueKey - ? leagues.find(l => l.key === selectedLeagueKey)?.name || t("home.select_league") - : t("home.select_league")} - - - )} - - ); - return ( - {renderHeader()} - {loading ? ( @@ -249,7 +88,21 @@ export default function HomeScreen() { item.id} - renderItem={({ item }) => } + renderItem={({ item }) => ( + { + router.push({ + pathname: "/live-detail/[id]", + params: { + id: m.id, + league_id: m.leagueId?.toString() || "", + sport_id: m.sportId?.toString() || "", + }, + }); + }} + /> + )} contentContainerStyle={styles.listContent} ListEmptyComponent={ @@ -258,49 +111,6 @@ export default function HomeScreen() { } /> )} - - {/* Modals */} - setShowSportModal(false)} - title={t("home.select_sport")} - options={sports.map((s) => { - const sportKeyMap: { [key: number]: string } = { - 1: "football", - 2: "basketball", - 3: "tennis", - 4: "cricket", - 5: "baseball", - 6: "badminton", - 7: "snooker", - 8: "volleyball", - }; - const sportKey = sportKeyMap[s.id] || s.name.toLowerCase(); - return { - id: s.id, - label: s.name, // 保留原始名称用于图标识别 - value: s.id, - icon: s.icon, - }; - })} - selectedValue={selectedSportId} - onSelect={setSelectedSportId} - /> - - setShowCalendarModal(false)} - selectedDate={selectedDate} - onSelectDate={setSelectedDate} - /> - - setShowLeagueModal(false)} - leagues={leagues} - selectedLeagueKey={selectedLeagueKey} - onSelect={handleLeagueSelect} - /> ); } @@ -315,44 +125,6 @@ const styles = StyleSheet.create({ alignItems: "center", paddingTop: 50, }, - filterContainer: { - flexDirection: "row", - paddingHorizontal: 16, - paddingVertical: 12, - gap: 12, - }, - filterBtn: { - flex: 1, - height: 44, // Increased from 36 - flexDirection: "column", // Stacked logic for Date, or Row for others - justifyContent: "center", - alignItems: "center", - borderRadius: 8, // Rounded corners - // iOS shadow - shadowColor: "#000", - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 2, - // Android elevation - elevation: 2, - }, - mainFilterBtn: { - flex: 2, // Wider for sport - flexDirection: "row", - gap: 8, - }, - filterText: { - fontSize: 14, - fontWeight: "500", - }, - dateDayText: { - fontSize: 16, - fontWeight: "bold", - }, - dateMonthText: { - fontSize: 10, - opacity: 0.6, - }, listContent: { padding: 16, paddingTop: 8, diff --git a/app/_layout.tsx b/app/_layout.tsx index 93f869b..a98f9ac 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,6 +8,7 @@ import { StatusBar } from "expo-status-bar"; import "react-native-reanimated"; import { Colors } from "@/constants/theme"; +import { AppStateProvider } from "@/context/AppStateContext"; import { ThemeProvider } from "@/context/ThemeContext"; import { useColorScheme } from "@/hooks/use-color-scheme"; import "@/i18n"; // Initialize i18n @@ -19,7 +20,9 @@ export const unstable_settings = { export default function RootLayout() { return ( - + + + ); } @@ -75,6 +78,13 @@ function RootLayoutNav() { headerShown: false, }} /> + diff --git a/app/live-detail/[id].tsx b/app/live-detail/[id].tsx new file mode 100644 index 0000000..6b7938e --- /dev/null +++ b/app/live-detail/[id].tsx @@ -0,0 +1,156 @@ +import { EventsTimeline } from "@/components/live-detail/events-timeline"; +import { LiveLeagueInfo } from "@/components/live-detail/live-league-info"; +import { LiveMatchTabs } from "@/components/live-detail/live-match-tabs"; +import { LiveScoreHeader } from "@/components/live-detail/live-score-header"; +import { OddsCard } from "@/components/live-detail/odds-card"; +import { OtherInfoCard } from "@/components/live-detail/other-info-card"; +import { StatsCard } from "@/components/live-detail/stats-card"; +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { Colors } from "@/constants/theme"; +import { useTheme } from "@/context/ThemeContext"; +import { fetchLiveScore } from "@/lib/api"; +import { LiveScoreMatch } from "@/types/api"; +import { useLocalSearchParams } from "expo-router"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export default function LiveDetailScreen() { + const { id, league_id, sport_id } = useLocalSearchParams<{ + id: string; + league_id: string; + sport_id: string; + }>(); + const { theme } = useTheme(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const isDark = theme === "dark"; + + const [loading, setLoading] = useState(true); + const [match, setMatch] = useState(null); + const [activeTab, setActiveTab] = useState("detail"); // Default to detail to show all data + + useEffect(() => { + loadLiveDetail(); + }, [id, league_id]); + + const loadLiveDetail = async () => { + try { + setLoading(true); + // Fetch live scores for the league + const sportId = parseInt(sport_id || "1"); + const leagueId = parseInt(league_id || "0"); + + const liveData = await fetchLiveScore(sportId, leagueId); + + if (liveData && Array.isArray(liveData)) { + // Find the specific match + const found = liveData.find((m) => m.event_key.toString() === id); + if (found) { + setMatch(found); + } + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (!match) { + return ( + + {t("detail.not_found")} + + {t("detail.retry")} + + + ); + } + + const renderTabContent = () => { + switch (activeTab) { + case "stats": + return ; + case "odds": + return ; + case "detail": + return ( + <> + + + + + + ); + default: + return ( + + + {t("detail.empty_stats")} + + + ); + } + }; + + return ( + + + + + + + {renderTabContent()} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + center: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 20, + minHeight: 200, + }, + retryButton: { + marginTop: 20, + paddingHorizontal: 20, + paddingVertical: 10, + backgroundColor: "#007AFF", + borderRadius: 8, + }, + retryText: { + color: "#FFF", + fontWeight: "600", + }, +}); diff --git a/components/live-detail/events-timeline.tsx b/components/live-detail/events-timeline.tsx new file mode 100644 index 0000000..2452fc6 --- /dev/null +++ b/components/live-detail/events-timeline.tsx @@ -0,0 +1,534 @@ +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { LiveScoreMatch } from "@/types/api"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, Switch, View } from "react-native"; + +interface EventItem { + time: string; + type: "goal" | "card" | "sub" | "status"; + side: "home" | "away" | "center"; + player?: string; + playerIn?: string; + playerOut?: string; + detail?: string; + score?: string; + isPenalty?: boolean; +} + +interface EventsTimelineProps { + match: LiveScoreMatch; + isDark: boolean; +} + +export function EventsTimeline({ match, isDark }: EventsTimelineProps) { + const { t } = useTranslation(); + const [showSub, setShowSub] = useState(true); + const [showCard, setShowCard] = useState(true); + + // Parse and merge events + const events: EventItem[] = []; + + // 1. Goals + match.goalscorers?.forEach((g) => { + const isHome = !!g.home_scorer; + events.push({ + time: g.time, + type: "goal", + side: isHome ? "home" : "away", + player: isHome ? g.home_scorer : g.away_scorer, + detail: isHome ? g.home_assist : g.away_assist, + score: g.score, + isPenalty: g.info?.toLowerCase().includes("penalty"), + }); + }); + + // 2. Cards + match.cards?.forEach((c) => { + const isHome = !!c.home_fault; + events.push({ + time: c.time, + type: "card", + side: isHome ? "home" : "away", + player: isHome ? c.home_fault : c.away_fault, + detail: c.card, // "yellow card" or "red card" + }); + }); + + // 3. Substitutes + match.substitutes?.forEach((s) => { + const isHome = Array.isArray(s.home_scorer) ? false : !!s.home_scorer?.in; + const isAway = Array.isArray(s.away_scorer) ? false : !!s.away_scorer?.in; + + if (isHome) { + const h = s.home_scorer as any; + events.push({ + time: s.time, + type: "sub", + side: "home", + playerIn: h.in, + playerOut: h.out, + }); + } + if (isAway) { + const a = s.away_scorer as any; + events.push({ + time: s.time, + type: "sub", + side: "away", + playerIn: a.in, + playerOut: a.out, + }); + } + }); + + // 4. Status items + events.push({ + time: "0", + type: "status", + side: "center", + detail: t("detail.events.start"), + }); + + if (match.event_halftime_result) { + events.push({ + time: "45", + type: "status", + side: "center", + detail: `${t("detail.events.ht")} ${match.event_halftime_result}`, + }); + } + + // Sort by time (Descending - Newer at top) + const sortedEvents = events.sort((a, b) => { + const timeA = parseInt(a.time.replace("'", "")) || 0; + const timeB = parseInt(b.time.replace("'", "")) || 0; + return timeB - timeA; + }); + + const filteredEvents = sortedEvents.filter((ev) => { + if (ev.type === "sub") return showSub; + if (ev.type === "card") return showCard; + return true; + }); + + return ( + + + + + {filteredEvents.map((event, index) => ( + + {/* Home Side */} + + {event.side === "home" && ( + + {event.type === "goal" && ( + + + {event.isPenalty + ? t("detail.events.penalty_goal") + : t("detail.events.goal")} + + + + + + + + {event.score} + + + + )} + {event.type === "card" && ( + + + + )} + {event.type === "sub" && ( + + + {event.playerIn} + + + {event.playerOut} + + + )} + + )} + + + {/* Time Point */} + + {event.type !== "status" ? ( + + + {`${event.time}'`} + + + ) : ( + + + {event.detail} + + + )} + + + {/* Away Side */} + + {event.side === "away" && ( + + {event.type === "goal" && ( + + + + {event.score} + + + + + + + + {event.isPenalty + ? t("detail.events.penalty_goal") + : t("detail.events.goal")} + + + )} + {event.type === "card" && ( + + + + )} + {event.type === "sub" && ( + + + {event.playerIn} + + + {event.playerOut} + + + )} + + )} + + + ))} + + + {/* Footer Controls */} + + + {t("detail.events.toggle_visibility")} + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + margin: 16, + borderRadius: 20, + backgroundColor: "#FFF", + padding: 16, + paddingBottom: 30, + shadowColor: "#000", + shadowOpacity: 0.05, + shadowRadius: 10, + elevation: 2, + }, + darkContainer: { + backgroundColor: "#1E1E20", + shadowOpacity: 0, + elevation: 0, + }, + darkItem: { + backgroundColor: "#2C2C2E", + borderColor: "#38383a", + }, + darkText: { + color: "#FFF", + }, + timelineWrapper: { + position: "relative", + alignItems: "center", + }, + centerLine: { + position: "absolute", + width: 1, + height: "100%", + backgroundColor: "#F0F0F0", + top: 0, + }, + darkLine: { + backgroundColor: "#333", + }, + eventRow: { + flexDirection: "row", + width: "100%", + alignItems: "center", + marginVertical: 12, + }, + sideContainer: { + flex: 1, + }, + timePointContainer: { + width: 60, + alignItems: "center", + justifyContent: "center", + }, + timeCircle: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: "#FFF", + justifyContent: "center", + alignItems: "center", + zIndex: 1, + borderWidth: 1, + borderColor: "#F0F0F0", + }, + timeText: { + fontSize: 12, + fontWeight: "500", + color: "#333", + }, + statusPill: { + position: "absolute", + backgroundColor: "#FFF", + borderWidth: 1, + borderColor: "#E0E0E0", + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 15, + zIndex: 2, + }, + statusText: { + fontSize: 14, + fontWeight: "bold", + }, + eventContent: { + flexDirection: "row", + alignItems: "center", + }, + homeContent: { + justifyContent: "flex-end", + paddingRight: 10, + }, + awayContent: { + justifyContent: "flex-start", + paddingLeft: 10, + }, + scorePill: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#FFF", + borderWidth: 1.5, + borderColor: "#FFD700", + borderRadius: 12, + paddingHorizontal: 6, + paddingVertical: 2, + gap: 4, + }, + scoreLabel: { + fontSize: 14, + fontWeight: "bold", + }, + eventLabel: { + fontSize: 12, + color: "#999", + }, + goalBox: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + playerIconPlaceholder: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: "#EEE", + borderWidth: 1, + borderColor: "#DDD", + }, + cardPill: { + width: 30, + height: 30, + borderRadius: 15, + borderWidth: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: "#FFF", + }, + miniCard: { + width: 10, + height: 14, + borderRadius: 2, + }, + subBox: { + alignItems: "flex-end", + }, + subInText: { + fontSize: 12, + color: "#333", + fontWeight: "500", + }, + subOutText: { + fontSize: 10, + color: "#999", + }, + penaltyIcon: { + width: 12, + height: 12, + backgroundColor: "#4B77FF", + borderRadius: 2, + justifyContent: "center", + alignItems: "center", + }, + footer: { + marginTop: 30, + borderTopWidth: 1, + borderTopColor: "#F0F0F0", + paddingTop: 16, + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + footerLabel: { + fontSize: 12, + color: "#999", + }, + footerSwitches: { + flexDirection: "row", + gap: 12, + }, + switchItem: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, + miniSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }], + }, +}); diff --git a/components/live-detail/live-league-info.tsx b/components/live-detail/live-league-info.tsx new file mode 100644 index 0000000..74bb8af --- /dev/null +++ b/components/live-detail/live-league-info.tsx @@ -0,0 +1,90 @@ +import { ThemedText } from "@/components/themed-text"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { LiveScoreMatch } from "@/types/api"; +import React from "react"; +import { Image, StyleSheet, TouchableOpacity, View } from "react-native"; + +interface LiveLeagueInfoProps { + match: LiveScoreMatch; +} + +export function LiveLeagueInfo({ match }: LiveLeagueInfoProps) { + // Force dark style for this component to match the header design language + const bgColor = "#121212"; + const borderColor = "rgba(255,255,255,0.1)"; + const textColor = "#FFF"; + + return ( + + + {match.league_logo ? ( + + ) : ( + + + + )} + + {match.league_name} + + + + + {match.league_round || ""} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 14, + borderBottomWidth: 1, + }, + left: { + flexDirection: "row", + alignItems: "center", + }, + leagueLogo: { + width: 24, + height: 24, + resizeMode: "contain", + marginRight: 8, + }, + fallbackLogo: { + width: 24, + height: 24, + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + marginRight: 8, + }, + leagueName: { + fontSize: 14, + fontWeight: "500", + }, + right: { + flexDirection: "row", + alignItems: "center", + }, + roundText: { + fontSize: 12, + color: "#888", + marginRight: 8, + }, +}); diff --git a/components/live-detail/live-match-tabs.tsx b/components/live-detail/live-match-tabs.tsx new file mode 100644 index 0000000..e3eccf5 --- /dev/null +++ b/components/live-detail/live-match-tabs.tsx @@ -0,0 +1,113 @@ +import { ThemedText } from "@/components/themed-text"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ScrollView, StyleSheet, TouchableOpacity, View } from "react-native"; + +interface LiveMatchTabsProps { + activeTab: string; + onTabChange: (tab: string) => void; + isDark: boolean; +} + +export function LiveMatchTabs({ + activeTab, + onTabChange, + isDark, +}: LiveMatchTabsProps) { + const { t } = useTranslation(); + const containerBg = isDark ? "#121212" : "#F5F5F5"; + + const tabs = [ + { + id: "detail", + label: t("detail.tabs.info"), + icon: "document-text-outline", + }, + { id: "stats", label: t("detail.tabs.stats"), icon: "stats-chart-outline" }, + { id: "odds", label: t("detail.tabs.odds"), icon: "cash-outline" }, + { id: "lineup", label: t("detail.tabs.lineup"), icon: "shirt-outline" }, + { + id: "analysis", + label: t("detail.tabs.analysis"), + icon: "pie-chart-outline", + }, + ]; + + return ( + + + {tabs.map((tab) => { + const isActive = activeTab === tab.id; + const activeColor = "#FF9800"; // 橙色 + const inactiveColor = isDark ? "#AAA" : "#666"; + const inactiveBg = isDark + ? "rgba(255,255,255,0.05)" + : "rgba(0,0,0,0.03)"; + + return ( + onTabChange(tab.id)} + style={[ + styles.tabBtn, + { + backgroundColor: isActive + ? isDark + ? "#2C2C2E" + : "#FFF" + : inactiveBg, + }, + ]} + > + + + {tab.label} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingVertical: 8, + }, + scrollContent: { + paddingHorizontal: 16, + gap: 10, + alignItems: "center", + }, + tabBtn: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 15, + paddingVertical: 6, + borderRadius: 20, + }, + tabLabel: { + fontSize: 14, + includeFontPadding: false, + }, +}); diff --git a/components/live-detail/live-score-header.tsx b/components/live-detail/live-score-header.tsx new file mode 100644 index 0000000..df2c719 --- /dev/null +++ b/components/live-detail/live-score-header.tsx @@ -0,0 +1,201 @@ +import { ThemedText } from "@/components/themed-text"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { LiveScoreMatch } from "@/types/api"; +import { LinearGradient } from "expo-linear-gradient"; +import { useRouter } from "expo-router"; +import React from "react"; +import { Image, StyleSheet, TouchableOpacity, View } from "react-native"; + +interface LiveScoreHeaderProps { + match: LiveScoreMatch; + topInset: number; +} + +export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) { + const router = useRouter(); + + return ( + + {/* Top Bar */} + + + router.back()} + style={styles.iconBtn} + > + + + + + + + {match.event_status || "Live"} + + + + + + + + + + + + + + {/* Score Section */} + + + + + + + {match.event_home_team} + + + + + + + {match.event_final_result?.replace(" - ", "-") || "0-0"} + + + {match.event_time} + {match.goalscorers && match.goalscorers.length > 0 && ( + + + + {match.goalscorers[match.goalscorers.length - 1].home_scorer || + match.goalscorers[match.goalscorers.length - 1].away_scorer} + + + )} + + + + + + + + {match.event_away_team} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingBottom: 24, + }, + topBar: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + marginBottom: 20, + }, + leftContainer: { + width: 60, + }, + rightActions: { + flexDirection: "row", + justifyContent: "flex-end", + width: 80, + }, + iconBtn: { + padding: 6, + marginLeft: 4, + }, + statusContainer: { + backgroundColor: "rgba(255,255,255,0.1)", + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 12, + }, + statusText: { + color: "#FFF", + fontWeight: "600", + fontSize: 14, + }, + scoreRow: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + paddingHorizontal: 24, + }, + teamInfo: { + alignItems: "center", + width: "30%", + }, + logoContainer: { + width: 70, + height: 70, + marginBottom: 10, + justifyContent: "center", + alignItems: "center", + }, + teamLogo: { + width: 60, + height: 60, + resizeMode: "contain", + }, + teamName: { + color: "#FFF", + fontSize: 14, + fontWeight: "700", + textAlign: "center", + lineHeight: 18, + }, + centerScore: { + alignItems: "center", + justifyContent: "center", + width: "40%", + paddingTop: 10, + }, + scoreBox: { + backgroundColor: "#FFF", + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 14, + marginBottom: 8, + minWidth: 100, + alignItems: "center", + }, + scoreValue: { + color: "#000", + fontSize: 30, + fontWeight: "700", + lineHeight: 30, + letterSpacing: 1, + }, + timeText: { + color: "#FF4444", + fontWeight: "700", + fontSize: 14, + marginBottom: 4, + }, + lastGoalContainer: { + flexDirection: "row", + alignItems: "center", + marginTop: 4, + opacity: 0.9, + }, + lastGoalText: { + color: "#FFF", + fontSize: 10, + marginLeft: 4, + }, +}); diff --git a/components/live-detail/odds-card.tsx b/components/live-detail/odds-card.tsx new file mode 100644 index 0000000..503d912 --- /dev/null +++ b/components/live-detail/odds-card.tsx @@ -0,0 +1,122 @@ +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { LiveScoreMatch } from "@/types/api"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, View } from "react-native"; + +interface OddsCardProps { + match: LiveScoreMatch; + isDark: boolean; +} + +export function OddsCard({ match, isDark }: OddsCardProps) { + const { t } = useTranslation(); + // 提取队名缩写或前3个字母 + const homeAbbr = + match.event_home_team?.substring(0, 3).toUpperCase() || "HOME"; + const awayAbbr = + match.event_away_team?.substring(0, 3).toUpperCase() || "AWAY"; + + return ( + + + + {t("detail.odds_card.title")} + + + bet365 + + + + + + {homeAbbr} + -0.93 + + + HDP + 0/0.5 + + + {awayAbbr} + 0.72 + + + + + {t("detail.odds_card.disclaimer")} + + + ); +} + +const styles = StyleSheet.create({ + container: { + margin: 16, + borderRadius: 20, + padding: 20, + backgroundColor: "#FFF", + marginBottom: 16, + shadowColor: "#000", + shadowOpacity: 0.05, + shadowRadius: 10, + elevation: 2, + }, + darkContainer: { + backgroundColor: "#1E1E20", + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: 20, + }, + title: { + fontSize: 18, + fontWeight: "500", + }, + badge: { + backgroundColor: "#1E4D40", + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + }, + badgeText: { + color: "#FFF", + fontSize: 14, + fontWeight: "bold", + }, + row: { + flexDirection: "row", + gap: 10, + }, + item: { + flex: 1, + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: "#F8F8F8", + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 14, + }, + darkItem: { + backgroundColor: "rgba(255,255,255,0.05)", + }, + team: { + fontSize: 16, + fontWeight: "500", + }, + odds: { + fontSize: 16, + fontWeight: "600", + color: "#FF9800", + }, + disclaimer: { + fontSize: 12, + color: "#BBB", + marginTop: 20, + textAlign: "center", + }, +}); diff --git a/components/live-detail/other-info-card.tsx b/components/live-detail/other-info-card.tsx new file mode 100644 index 0000000..570f25a --- /dev/null +++ b/components/live-detail/other-info-card.tsx @@ -0,0 +1,81 @@ +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { LiveScoreMatch } from "@/types/api"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, View } from "react-native"; + +interface OtherInfoCardProps { + match: LiveScoreMatch; + isDark: boolean; +} + +export function OtherInfoCard({ match, isDark }: OtherInfoCardProps) { + const { t } = useTranslation(); + const iconColor = isDark ? "#888" : "#999"; + + // 模拟气象数据,因为当前 API 响应中未直接包含这些字段 + const weather = { + temp: "25°C", + wind: "3.4m/s", + humidity: "31%", + }; + + return ( + + + {t("detail.other_info.title")} + + + + + {weather.temp} + + + + + {weather.wind} + + + + + {weather.humidity} + + + ); +} + +const styles = StyleSheet.create({ + container: { + margin: 16, + borderRadius: 20, + backgroundColor: "#FFF", + padding: 20, + marginBottom: 20, + shadowColor: "#000", + shadowOpacity: 0.05, + shadowRadius: 10, + elevation: 2, + }, + darkContainer: { + backgroundColor: "#1E1E20", + shadowOpacity: 0, + elevation: 0, + }, + title: { + fontSize: 16, + color: "#666", + marginBottom: 20, + }, + infoRow: { + flexDirection: "row", + alignItems: "center", + gap: 15, + marginBottom: 16, + }, + infoText: { + fontSize: 18, + fontWeight: "500", + }, +}); diff --git a/components/live-detail/stats-card.tsx b/components/live-detail/stats-card.tsx new file mode 100644 index 0000000..f83fb21 --- /dev/null +++ b/components/live-detail/stats-card.tsx @@ -0,0 +1,704 @@ +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { LiveScoreMatch } from "@/types/api"; +import { LinearGradient } from "expo-linear-gradient"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Image, + Modal, + Pressable, + StyleSheet, + Switch, + TouchableOpacity, + View, +} from "react-native"; + +interface StatsCardProps { + match: LiveScoreMatch; + isDark: boolean; +} + +export function StatsCard({ match, isDark }: StatsCardProps) { + const { t } = useTranslation(); + const [flagSwitch, setFlagSwitch] = useState(true); + const [cardSwitch, setCardSwitch] = useState(true); + const [showInfo, setShowInfo] = useState(false); + + // 从 statistics 中提取数据 + const stats = match.statistics || []; + const getStatValue = (type: string) => { + const stat = stats.find((s) => s.type === type); + return stat + ? { home: stat.home, away: stat.away } + : { home: "0", away: "0" }; + }; + + const possession = getStatValue("Ball Possession"); + const corners = getStatValue("Corners"); + const yellowCards = getStatValue("Yellow Cards"); + + // 解析控球率数值 (例如 "53%" -> 53) + const homePossession = + parseInt(possession.home.toString().replace("%", "")) || 50; + const awayPossession = + parseInt(possession.away.toString().replace("%", "")) || 50; + + // 用随机数据模拟压力曲线 + const generateData = () => { + return Array.from({ length: 90 }, (_, i) => ({ + time: i, + home: Math.sin(i / 5) * 20 + Math.random() * 10, + away: Math.cos(i / 4) * 15 + Math.random() * 12, + })); + }; + + const chartData = generateData(); + + const iconTintColor = isDark ? "#888" : "#CCC"; + const switchBg = isDark ? "#2C2C2E" : "#F0F0F0"; + + return ( + + {/* Header */} + + + + {t("detail.stats_card.title")} + + setShowInfo(true)}> + + + + + + + + + + + + + + + + {/* Momentum Chart Area */} + + + + + + + + {/* 背景色带 */} + + + {/* 网格线和时间刻度 */} + + {["0'", "15'", "30'", "HT", "60'", "75'", "90'"].map((label, i) => ( + + + {label} + + ))} + + + {/* 压力曲线模拟 (使用密集的小条形模拟波形) */} + + {chartData.map((d, i) => ( + + + + + ))} + + + {/* 比赛事件标记 (假点位) */} + + + + + + + + + + + + + + + + + + + {/* Time Progress Slider */} + + 46:53 + + + + + + + + + 0:00 + HT + FT + + + + {/* Possession & Stats Bar */} + + + {t("detail.stats_card.possession")} + + + + + {homePossession}% + + + + + {awayPossession}% + + + + + + {corners.home} + + {corners.away} + + + + {yellowCards.home} + + + + {yellowCards.away} + + + + + + {/* Momentum Info Modal */} + setShowInfo(false)} + > + setShowInfo(false)} + > + + + {t("detail.stats_card.info_title")} + + + + {t("detail.stats_card.info_desc")} + + + + + • {t("detail.stats_card.point1")} + + + + + • {t("detail.stats_card.point2")} + + + + + • {t("detail.stats_card.point3")} + + + + + + + {[...Array(6)].map((_, i) => ( + + ))} + + + {match.event_home_team} + + + + + + + {[...Array(6)].map((_, i) => ( + + ))} + + + {match.event_away_team} + + + + setShowInfo(false)} + > + + {t("detail.stats_card.close")} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + margin: 16, + borderRadius: 20, + padding: 16, + backgroundColor: "#FFF", + marginBottom: 20, + shadowColor: "#000", + shadowOpacity: 0.05, + shadowRadius: 10, + elevation: 2, + }, + darkContainer: { + backgroundColor: "#1E1E20", + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 24, + }, + headerLeft: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + title: { + fontSize: 18, + fontWeight: "500", + color: "#666", + }, + switches: { + flexDirection: "row", + gap: 12, + }, + switchWrapper: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#F0F0F0", + borderRadius: 15, + paddingLeft: 8, + paddingRight: 2, + height: 30, + }, + smallSwitch: { + transform: [{ scaleX: 0.6 }, { scaleY: 0.6 }], + }, + miniCard: { + width: 10, + height: 14, + borderRadius: 2, + }, + chartWrapper: { + flexDirection: "row", + height: 140, + marginBottom: 10, + }, + teamLogos: { + width: 40, + justifyContent: "center", + alignItems: "center", + paddingBottom: 20, + }, + chartLogo: { + width: 24, + height: 24, + borderRadius: 12, + }, + chartArea: { + flex: 1, + position: "relative", + marginLeft: 8, + }, + bgGradient: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 30, + borderRadius: 4, + }, + gridContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + flexDirection: "row", + justifyContent: "space-between", + }, + gridLine: { + height: "100%", + alignItems: "center", + }, + line: { + width: 1, + height: "100%", + backgroundColor: "#F0F0F0", + marginBottom: 4, + }, + gridLabel: { + fontSize: 12, + color: "#000", + fontWeight: "500", + }, + waveContainer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 30, + }, + waveBar: { + position: "absolute", + width: 1.5, + }, + eventMarker: { + position: "absolute", + alignItems: "center", + }, + square: { + width: 12, + height: 16, + borderRadius: 2, + }, + goalBox: { + flexDirection: "row", + alignItems: "center", + gap: 2, + backgroundColor: "#FFF", + padding: 2, + borderRadius: 4, + borderWidth: 1, + borderColor: "#E0E0E0", + }, + sliderContainer: { + marginTop: 20, + paddingHorizontal: 10, + }, + currentTime: { + fontSize: 14, + color: "#4CAF50", + fontWeight: "700", + textAlign: "center", + marginBottom: 4, + marginLeft: "10%", // Offset roughly + }, + trackContainer: { + height: 20, + justifyContent: "center", + position: "relative", + }, + track: { + height: 3, + backgroundColor: "#EEE", + borderRadius: 2, + }, + filledTrack: { + position: "absolute", + height: 3, + backgroundColor: "#3b5998", + borderRadius: 2, + }, + thumb: { + position: "absolute", + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: "#3b5998", + borderWidth: 2, + borderColor: "#FFF", + marginLeft: -5, + }, + trackLabels: { + flexDirection: "row", + justifyContent: "space-between", + marginTop: 8, + }, + trackLabel: { + fontSize: 12, + color: "#666", + fontWeight: "500", + }, + possessionSection: { + marginTop: 24, + alignItems: "center", + }, + possessionTitle: { + fontSize: 16, + color: "#666", + marginBottom: 12, + }, + possessionBarRow: { + flexDirection: "row", + height: 36, + width: "100%", + paddingHorizontal: 4, + }, + posBar: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 12, + gap: 8, + }, + posLogo: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: "#FFF", + }, + posValue: { + color: "#FFF", + fontSize: 14, + fontWeight: "700", + }, + miniStatsRow: { + flexDirection: "row", + justifyContent: "center", + gap: 12, + marginTop: 16, + }, + miniStatBadge: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#F5F5F5", + paddingHorizontal: 16, + paddingVertical: 6, + borderRadius: 20, + gap: 8, + minWidth: 80, + justifyContent: "center", + }, + darkStatBadge: { + backgroundColor: "#2C2C2E", + }, + miniStatText: { + fontSize: 14, + fontWeight: "600", + }, + yellowCardIcon: { + width: 10, + height: 14, + backgroundColor: "#FFD700", + borderRadius: 2, + }, + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.5)", + justifyContent: "center", + alignItems: "center", + padding: 20, + }, + modalContent: { + width: "100%", + backgroundColor: "#FFF", + borderRadius: 24, + padding: 24, + shadowColor: "#000", + shadowOpacity: 0.1, + shadowRadius: 20, + elevation: 10, + }, + darkModalContent: { + backgroundColor: "#1E1E20", + }, + modalTitle: { + fontSize: 20, + fontWeight: "700", + marginBottom: 16, + }, + modalDesc: { + fontSize: 15, + lineHeight: 22, + marginBottom: 16, + }, + bulletPoint: { + marginBottom: 8, + }, + bulletText: { + fontSize: 15, + lineHeight: 22, + }, + teamInfoRow: { + flexDirection: "row", + alignItems: "center", + marginTop: 16, + gap: 12, + }, + miniLogo: { + width: 24, + height: 24, + borderRadius: 12, + }, + wavePlaceholder: { + flexDirection: "row", + alignItems: "center", + width: 40, + height: 20, + gap: 2, + }, + waveDot: { + width: 4, + height: 4, + borderRadius: 2, + }, + teamDesc: { + flex: 1, + fontSize: 14, + color: "#666", + }, + closeButton: { + backgroundColor: "#4B77FF", + borderRadius: 12, + paddingVertical: 12, + alignItems: "center", + marginTop: 24, + alignSelf: "flex-end", + paddingHorizontal: 24, + }, + closeButtonText: { + color: "#FFF", + fontSize: 16, + fontWeight: "600", + }, +}); diff --git a/components/match-card.tsx b/components/match-card.tsx index 882158c..635752f 100644 --- a/components/match-card.tsx +++ b/components/match-card.tsx @@ -9,9 +9,10 @@ import { Pressable, StyleSheet, View } from "react-native"; interface MatchCardProps { match: Match; + onPress?: (match: Match) => void; } -export function MatchCard({ match }: MatchCardProps) { +export function MatchCard({ match, onPress }: MatchCardProps) { const router = useRouter(); const { theme } = useTheme(); const isDark = theme === "dark"; @@ -20,7 +21,11 @@ export function MatchCard({ match }: MatchCardProps) { const borderColor = isDark ? "#38383A" : "#E5E5EA"; const handlePress = () => { - router.push(`/match-detail/${match.id}`); + if (onPress) { + onPress(match); + } else { + router.push(`/match-detail/${match.id}`); + } }; return ( diff --git a/constants/api.ts b/constants/api.ts index 3c0de18..39aba38 100644 --- a/constants/api.ts +++ b/constants/api.ts @@ -8,6 +8,7 @@ export const API_ENDPOINTS = { COUNTRIES: "/v1/api/countries", LEAGUES: "/v1/api/leagues", MATCHES_TODAY: "/v1/api/matches/today", + LIVESCORE: "/v1/api/livescore", UPCOMING_MATCHES: "/v1/api/matches/upcoming", MATCH_DETAIL: (id: string) => `/v1/api/matches/${id}`, ODDS: "/v1/api/odds", diff --git a/context/AppStateContext.tsx b/context/AppStateContext.tsx new file mode 100644 index 0000000..4215cd2 --- /dev/null +++ b/context/AppStateContext.tsx @@ -0,0 +1,67 @@ +import React, { createContext, ReactNode, useContext, useState } from "react"; + +interface AppState { + selectedSportId: number | null; + selectedDate: Date; + selectedLeagueKey: string | null; + timezone: string; +} + +interface AppStateContextType { + state: AppState; + updateSportId: (sportId: number | null) => void; + updateDate: (date: Date) => void; + updateLeagueKey: (leagueKey: string | null) => void; + updateTimezone: (timezone: string) => void; +} + +const AppStateContext = createContext( + undefined +); + +export function AppStateProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + selectedSportId: 1, // 默认足球 + selectedDate: new Date(), + selectedLeagueKey: null, + timezone: "UTC", + }); + + const updateSportId = (sportId: number | null) => { + setState((prev) => ({ ...prev, selectedSportId: sportId })); + }; + + const updateDate = (date: Date) => { + setState((prev) => ({ ...prev, selectedDate: date })); + }; + + const updateLeagueKey = (leagueKey: string | null) => { + setState((prev) => ({ ...prev, selectedLeagueKey: leagueKey })); + }; + + const updateTimezone = (timezone: string) => { + setState((prev) => ({ ...prev, timezone })); + }; + + return ( + + {children} + + ); +} + +export function useAppState() { + const context = useContext(AppStateContext); + if (!context) { + throw new Error("useAppState must be used within AppStateProvider"); + } + return context; +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index b8e4e75..3abf943 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -43,6 +43,8 @@ "info": "Details", "stats": "Statistics", "odds": "Odds", + "lineup": "Lineup", + "analysis": "Analysis", "h2h": "H2H", "chat": "Chat" }, @@ -68,13 +70,35 @@ "empty_odds": "No odds data", "empty_h2h": "No H2H data", "empty_chat": "Chat is not available", + "odds_card": { + "title": "Match Odds", + "disclaimer": "18+. Please gamble responsibly. Odds are subject to change." + }, + "stats_card": { + "title": "Statistics", + "possession": "Possession", + "info_title": "Momentum Explanation", + "info_desc": "The momentum chart reflects the attacking pressure and threat level of the two teams during different periods of the match.", + "point1": "The upper waves represent the home team, and the lower waves represent the away team.", + "point2": "The height of the waves depends on attacking intensity, possession time, and shot threat.", + "point3": "The more intense the solid line fluctuates, the higher the attacking frequency in that period.", + "close": "Close" + }, + "other_info": { + "title": "Other Info" + }, "events": { "goals": "Goals", "cards": "Cards", "substitutes": "Substitutes", "lineups": "Lineups", "red_card": "RED", - "yellow_card": "YELLOW" + "yellow_card": "YELLOW", + "start": "Match Started", + "ht": "HT", + "goal": "Goal", + "penalty_goal": "Penalty Goal", + "toggle_visibility": "Toggle timeline events" } }, "selection": { diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 528e0c5..062c409 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -43,6 +43,8 @@ "info": "详情", "stats": "统计数据", "odds": "赔率", + "lineup": "阵容", + "analysis": "数据分析", "h2h": "交锋往绩", "chat": "聊天" }, @@ -68,13 +70,35 @@ "empty_odds": "暂无赔率数据", "empty_h2h": "暂无交锋数据", "empty_chat": "聊天功能暂未开启", + "odds_card": { + "title": "比赛赔率", + "disclaimer": "18+. 请负责任地赌博。赔率可能会变动。" + }, + "stats_card": { + "title": "统计数据", + "possession": "控球率", + "info_title": "势头说明", + "info_desc": "势头图反映了两支球队在比赛不同时间段的进攻压力和威胁度。", + "point1": "上方波浪代表主队,下方代表客队。", + "point2": "波浪的高度取决于进攻强度、控球时间和射门威胁。", + "point3": "实线波动越剧烈,代表该时段进攻频率越高。", + "close": "关闭" + }, + "other_info": { + "title": "其他信息" + }, "events": { "goals": "进球", "cards": "红黄牌", "substitutes": "换人", "lineups": "首发阵容", "red_card": "红牌", - "yellow_card": "黄牌" + "yellow_card": "黄牌", + "start": "比赛开始", + "ht": "半场", + "goal": "进球", + "penalty_goal": "点球进球", + "toggle_visibility": "显示/隐藏时间线事件" } }, "selection": { diff --git a/lib/api.ts b/lib/api.ts index ed03dff..e0470cf 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -4,6 +4,7 @@ import { ApiResponse, Country, League, + LiveScoreMatch, Match, MatchDetailData, OddsData, @@ -138,23 +139,56 @@ export const fetchMatchDetail = async ( } }; +export const fetchLiveScore = async ( + sportId: number, + leagueId?: number, + timezone?: string +): Promise => { + try { + const params: { sport_id: number; league_id?: number; timezone?: string } = + { + sport_id: sportId, + }; + + if (leagueId) { + params.league_id = leagueId; + } + + if (timezone) { + params.timezone = timezone; + } + + const response = await apiClient.get>( + API_ENDPOINTS.LIVESCORE, + { params } + ); + + if (response.data.code === 0) { + return response.data.data; + } + + throw new Error(response.data.message); + } catch (error) { + console.error("Fetch livescore error:", error); + throw error; + } +}; + export const fetchUpcomingMatches = async ( sportId: number, leagueKey: string, limit: number = 50 ): Promise => { try { - const response = - await apiClient.get>>( - API_ENDPOINTS.UPCOMING_MATCHES, - { - params: { - sport_id: sportId, - leagueKey, - limit, - }, - } - ); + const response = await apiClient.get< + ApiResponse> + >(API_ENDPOINTS.UPCOMING_MATCHES, { + params: { + sport_id: sportId, + leagueKey, + limit, + }, + }); if (response.data.code === 0) { return response.data.data.list; diff --git a/types/api.ts b/types/api.ts index f01211a..88fef1c 100644 --- a/types/api.ts +++ b/types/api.ts @@ -17,6 +17,75 @@ export interface Match { meta?: string; scoreText: string; fav: boolean; + leagueId?: number; + sportId?: number; + isLive?: boolean; +} + +export interface LiveScoreMatch { + event_key: number; + event_date: string; + event_time: string; + event_home_team: string; + home_team_key: number; + home_team_logo: string; + event_away_team: string; + away_team_key: number; + away_team_logo: string; + event_final_result: string; + event_halftime_result: string; + event_status: string; + event_live: string; + league_key: number; + league_name: string; + league_logo: string; + league_round: string; + league_season: string; + country_name: string; + country_logo: string; + event_country_key: number; + goalscorers?: { + time: string; + home_scorer: string; + home_scorer_id: string; + home_assist: string; + home_assist_id: string; + score: string; + away_scorer: string; + away_scorer_id: string; + away_assist: string; + away_assist_id: string; + info: string; + info_time: string; + }[]; + cards?: { + time: string; + home_fault: string; + card: string; + away_fault: string; + info: string; + home_player_id: string; + away_player_id: string; + info_time: string; + }[]; + substitutes?: { + time: string; + home_scorer: + | { in: string; out: string; in_id: number; out_id: number } + | any[]; + away_scorer: + | { in: string; out: string; in_id: number; out_id: number } + | any[]; + info: string; + info_time: string; + score: string; + }[]; + lineups?: unknown; + statistics?: { + type: string; + home: string; + away: string; + }[]; } export interface ApiResponse {