From 9c1658699455df0d63f78a8aba822a5802a0907e Mon Sep 17 00:00:00 2001 From: yuchenglong Date: Tue, 13 Jan 2026 09:26:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AF=A6=E6=83=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/_layout.tsx | 39 ++-- app/match-detail/[id].tsx | 160 ++++++++++++++++ components/match-card.tsx | 18 +- components/match-detail/league-info.tsx | 84 +++++++++ components/match-detail/match-info-card.tsx | 62 +++++++ components/match-detail/match-tabs.tsx | 92 +++++++++ components/match-detail/score-header.tsx | 196 ++++++++++++++++++++ components/match-detail/score-table.tsx | 132 +++++++++++++ constants/api.ts | 3 +- i18n/locales/en.json | 27 +++ i18n/locales/zh.json | 27 +++ lib/api.ts | 29 ++- types/api.ts | 54 +++++- 13 files changed, 902 insertions(+), 21 deletions(-) create mode 100644 app/match-detail/[id].tsx create mode 100644 components/match-detail/league-info.tsx create mode 100644 components/match-detail/match-info-card.tsx create mode 100644 components/match-detail/match-tabs.tsx create mode 100644 components/match-detail/score-header.tsx create mode 100644 components/match-detail/score-table.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index 9ff029f..3c9d5bf 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,14 +1,18 @@ -import { DarkTheme, DefaultTheme, ThemeProvider as NavigationThemeProvider } from '@react-navigation/native'; -import { Stack } from 'expo-router'; -import { StatusBar } from 'expo-status-bar'; -import 'react-native-reanimated'; +import { + DarkTheme, + DefaultTheme, + ThemeProvider as NavigationThemeProvider, +} from "@react-navigation/native"; +import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import "react-native-reanimated"; -import { useColorScheme } from '@/hooks/use-color-scheme'; -import { ThemeProvider } from '@/context/ThemeContext'; -import '@/i18n'; // Initialize i18n +import { ThemeProvider } from "@/context/ThemeContext"; +import { useColorScheme } from "@/hooks/use-color-scheme"; +import "@/i18n"; // Initialize i18n export const unstable_settings = { - anchor: '(tabs)', + anchor: "(tabs)", }; export default function RootLayout() { @@ -23,12 +27,25 @@ function RootLayoutNav() { const colorScheme = useColorScheme(); return ( - + - + + - + ); } diff --git a/app/match-detail/[id].tsx b/app/match-detail/[id].tsx new file mode 100644 index 0000000..0ad79ab --- /dev/null +++ b/app/match-detail/[id].tsx @@ -0,0 +1,160 @@ +import { LeagueInfo } from "@/components/match-detail/league-info"; +import { MatchInfoCard } from "@/components/match-detail/match-info-card"; +import { MatchTabs } from "@/components/match-detail/match-tabs"; +import { ScoreHeader } from "@/components/match-detail/score-header"; +import { ScoreTable } from "@/components/match-detail/score-table"; +import { ThemedText } from "@/components/themed-text"; +import { ThemedView } from "@/components/themed-view"; +import { Colors } from "@/constants/theme"; +import { useTheme } from "@/context/ThemeContext"; +import { fetchMatchDetail } from "@/lib/api"; +import { MatchDetailData } from "@/types/api"; +import { Stack, useLocalSearchParams, useRouter } 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 MatchDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const { theme } = useTheme(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + const isDark = theme === "dark"; + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [activeTab, setActiveTab] = useState("info"); + + useEffect(() => { + if (id) { + loadMatchDetail(); + } + }, [id]); + + const loadMatchDetail = async () => { + try { + setLoading(true); + setError(null); + const result = await fetchMatchDetail(id as string); + setData(result); + } catch (err: any) { + setError(err.message || t("detail.fetch_failed")); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (error || !data) { + return ( + + {error || t("detail.not_found")} + + {t("detail.retry")} + + + ); + } + + const renderTabContent = () => { + switch (activeTab) { + case "info": + return ( + <> + + + + ); + case "h2h": + return ( + + + {t("detail.empty_h2h")} + + + ); + case "chat": + return ( + + + {t("detail.empty_chat")} + + + ); + default: + return null; + } + }; + + return ( + + + + + + + + + {renderTabContent()} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + center: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 20, + }, + errorText: { + fontSize: 16, + marginBottom: 20, + textAlign: "center", + }, + retryButton: { + paddingHorizontal: 20, + paddingVertical: 10, + backgroundColor: "#007AFF", + borderRadius: 8, + }, + retryText: { + color: "#FFF", + fontWeight: "600", + }, + emptyContent: { + padding: 50, + alignItems: "center", + }, + emptyText: { + opacity: 0.5, + }, +}); diff --git a/components/match-card.tsx b/components/match-card.tsx index a743bb9..882158c 100644 --- a/components/match-card.tsx +++ b/components/match-card.tsx @@ -3,22 +3,34 @@ import { IconSymbol } from "@/components/ui/icon-symbol"; import { Colors } from "@/constants/theme"; import { useTheme } from "@/context/ThemeContext"; import { Match } from "@/types/api"; +import { useRouter } from "expo-router"; import React from "react"; -import { StyleSheet, View } from "react-native"; +import { Pressable, StyleSheet, View } from "react-native"; interface MatchCardProps { match: Match; } export function MatchCard({ match }: MatchCardProps) { + const router = useRouter(); const { theme } = useTheme(); const isDark = theme === "dark"; const iconColor = isDark ? Colors.dark.icon : Colors.light.icon; const cardBg = isDark ? "#1C1C1E" : "#FFFFFF"; const borderColor = isDark ? "#38383A" : "#E5E5EA"; + const handlePress = () => { + router.push(`/match-detail/${match.id}`); + }; + return ( - + [ + styles.card, + { backgroundColor: cardBg, borderColor, opacity: pressed ? 0.7 : 1 }, + ]} + > {match.meta} )} - + ); } diff --git a/components/match-detail/league-info.tsx b/components/match-detail/league-info.tsx new file mode 100644 index 0000000..7b6b522 --- /dev/null +++ b/components/match-detail/league-info.tsx @@ -0,0 +1,84 @@ +import { ThemedText } from "@/components/themed-text"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { MatchDetailData } from "@/types/api"; +import React from "react"; +import { Image, StyleSheet, TouchableOpacity, View } from "react-native"; + +interface LeagueInfoProps { + data: MatchDetailData; + isDark: boolean; +} + +export function LeagueInfo({ data, isDark }: LeagueInfoProps) { + const { match } = data; + const bgColor = isDark ? "#1C1C1E" : "#FFF"; + const borderColor = isDark ? "#2C2C2E" : "#EEE"; + + return ( + + + {match.leagueLogo ? ( + + ) : ( + + + + )} + {match.leagueName} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 12, + 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", + }, +}); diff --git a/components/match-detail/match-info-card.tsx b/components/match-detail/match-info-card.tsx new file mode 100644 index 0000000..3623915 --- /dev/null +++ b/components/match-detail/match-info-card.tsx @@ -0,0 +1,62 @@ +import { ThemedText } from "@/components/themed-text"; +import { MatchDetailData } from "@/types/api"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, View } from "react-native"; + +interface MatchInfoCardProps { + data: MatchDetailData; + isDark: boolean; +} + +export function MatchInfoCard({ data, isDark }: MatchInfoCardProps) { + const { t } = useTranslation(); + const { match } = data; + const bgColor = isDark ? "#1C1C1E" : "#FFF"; + + return ( + + + {t("detail.info_card.title")} + + + + {match.leagueName} + + {match.eventDate} • {match.eventTime} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + margin: 16, + marginTop: 0, + borderRadius: 12, + padding: 16, + elevation: 2, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + title: { + fontSize: 14, + fontWeight: "600", + color: "#666", + marginBottom: 16, + }, + content: { + gap: 4, + }, + leagueName: { + fontSize: 16, + fontWeight: "600", + }, + dateTime: { + fontSize: 14, + opacity: 0.6, + }, +}); diff --git a/components/match-detail/match-tabs.tsx b/components/match-detail/match-tabs.tsx new file mode 100644 index 0000000..eb37553 --- /dev/null +++ b/components/match-detail/match-tabs.tsx @@ -0,0 +1,92 @@ +import { ThemedText } from "@/components/themed-text"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ScrollView, StyleSheet, TouchableOpacity, View } from "react-native"; + +interface MatchTabsProps { + activeTab: string; + onTabChange: (tab: string) => void; + isDark: boolean; +} + +export function MatchTabs({ activeTab, onTabChange, isDark }: MatchTabsProps) { + const { t } = useTranslation(); + const containerBg = isDark ? "#121212" : "#F8F8F8"; + + const tabs = [ + { id: "info", label: t("detail.tabs.info") }, + { id: "h2h", label: t("detail.tabs.h2h") }, + { id: "chat", label: t("detail.tabs.chat") }, + ]; + + return ( + + + {tabs.map((tab) => { + const isActive = activeTab === tab.id; + return ( + onTabChange(tab.id)} + style={[ + styles.tabBtn, + isActive && styles.activeTabBtn, + isActive && { + backgroundColor: isDark ? "rgba(255,255,255,0.05)" : "#FFF", + }, + ]} + > + + {tab.label} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingVertical: 12, + }, + scrollContent: { + paddingHorizontal: 16, + gap: 12, + }, + tabBtn: { + paddingHorizontal: 20, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + borderColor: "transparent", + }, + activeTabBtn: { + borderColor: "rgba(255,152,0,0.3)", + // Shadow for iOS/Android if needed + elevation: 2, + shadowColor: "#000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + }, + tabText: { + fontSize: 14, + fontWeight: "500", + opacity: 0.6, + }, + activeTabText: { + opacity: 1, + }, +}); diff --git a/components/match-detail/score-header.tsx b/components/match-detail/score-header.tsx new file mode 100644 index 0000000..cf1de4a --- /dev/null +++ b/components/match-detail/score-header.tsx @@ -0,0 +1,196 @@ +import { ThemedText } from "@/components/themed-text"; +import { IconSymbol } from "@/components/ui/icon-symbol"; +import { MatchDetailData } from "@/types/api"; +import { LinearGradient } from "expo-linear-gradient"; +import { useRouter } from "expo-router"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Image, StyleSheet, TouchableOpacity, View } from "react-native"; + +interface ScoreHeaderProps { + data: MatchDetailData; + isDark: boolean; + topInset: number; +} + +export function ScoreHeader({ data, isDark, topInset }: ScoreHeaderProps) { + const router = useRouter(); + const { t } = useTranslation(); + const { match } = data; + + return ( + + {/* Top Bar */} + + + router.back()} + style={styles.iconBtn} + > + + + + + + + {match.eventStatus || t("detail.pending")} + + + + + + + + + + + + + + {/* Score Section */} + + + + + + + {match.eventHomeTeam} + + + + + + + {match.eventFinalResult && match.eventFinalResult !== "" + ? match.eventFinalResult + : "0 - 0"} + + + + + + + + + + + + + {match.eventAwayTeam} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingBottom: 30, + paddingHorizontal: 16, + }, + topBar: { + flexDirection: "row", + alignItems: "center", + marginBottom: 20, + minHeight: 40, + }, + leftContainer: { + flex: 1, + flexDirection: "row", + alignItems: "center", + }, + statusContainer: { + flex: 2, + justifyContent: "center", + alignItems: "center", + }, + rightContainer: { + flex: 1, + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center", + gap: 12, + }, + statusText: { + color: "#FFF", + fontSize: 16, + fontWeight: "600", + opacity: 0.8, + }, + rightIcons: { + flexDirection: "row", + gap: 12, + }, + iconBtn: { + width: 36, + height: 36, + justifyContent: "center", + alignItems: "center", + }, + scoreRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 10, + }, + teamInfo: { + flex: 1.2, + alignItems: "center", + }, + logoContainer: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: "rgba(255,255,255,0.1)", + justifyContent: "center", + alignItems: "center", + marginBottom: 8, + overflow: "hidden", + }, + teamLogo: { + width: 48, + height: 48, + resizeMode: "contain", + }, + teamName: { + color: "#FFF", + fontSize: 14, + fontWeight: "600", + textAlign: "center", + }, + centerScore: { + flex: 1, + alignItems: "center", + }, + scoreBox: { + backgroundColor: "#FFF", + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 8, + marginBottom: 12, + }, + scoreValue: { + color: "#000", + fontSize: 32, + fontWeight: "bold", + letterSpacing: 2, + }, + fieldBtn: { + backgroundColor: "#0055FF", + width: 80, + height: 28, + borderRadius: 14, + justifyContent: "center", + alignItems: "center", + }, +}); diff --git a/components/match-detail/score-table.tsx b/components/match-detail/score-table.tsx new file mode 100644 index 0000000..fc3801c --- /dev/null +++ b/components/match-detail/score-table.tsx @@ -0,0 +1,132 @@ +import { ThemedText } from "@/components/themed-text"; +import { MatchDetailData } from "@/types/api"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Image, StyleSheet, View } from "react-native"; + +interface ScoreTableProps { + data: MatchDetailData; + isDark: boolean; +} + +export function ScoreTable({ data, isDark }: ScoreTableProps) { + const { t } = useTranslation(); + const { match } = data; + const bgColor = isDark ? "#1C1C1E" : "#FFF"; + const headerTextColor = isDark ? "#666" : "#999"; + + // Mock quarters for demo purposes as seen in screenshot + // In real app, these would come from the API (e.g. match.eventQuarter or specific fields) + const headers = [ + t("detail.score_table.team"), + t("detail.score_table.total"), + "1", + "2", + "3", + "4", + ]; + const rows = [ + { + logo: match.homeTeamLogo, + name: match.eventHomeTeam, + total: 0, + q1: 0, + q2: 0, + q3: 0, + q4: 0, + }, + { + logo: match.awayTeamLogo, + name: match.eventAwayTeam, + total: 0, + q1: 0, + q2: 0, + q3: 0, + q4: 0, + }, + ]; + + return ( + + + {headers.map((h, i) => ( + + {h} + + ))} + + + {rows.map((row, idx) => ( + + + + + {row.total} + {row.q1} + {row.q2} + {row.q3} + {row.q4} + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { + margin: 16, + borderRadius: 12, + padding: 16, + // Shadow + elevation: 2, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + header: { + flexDirection: "row", + paddingBottom: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "rgba(150,150,150,0.2)", + }, + headerText: { + fontSize: 12, + fontWeight: "500", + }, + row: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 12, + }, + rowBorder: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "rgba(150,150,150,0.1)", + }, + teamCell: { + flex: 3, + flexDirection: "row", + alignItems: "center", + }, + teamLogo: { + width: 24, + height: 24, + resizeMode: "contain", + }, + cellText: { + flex: 1, + textAlign: "center", + fontSize: 14, + fontWeight: "500", + }, +}); diff --git a/constants/api.ts b/constants/api.ts index a5fca7b..5c93bb8 100644 --- a/constants/api.ts +++ b/constants/api.ts @@ -1,9 +1,10 @@ export const API_CONFIG = { - BASE_URL: "http://192.168.1.66:8000", // Update this with your machine IP if running on device ex: http://192.168.1.5:8000 + BASE_URL: "http://192.168.1.111:8000", // Update this with your machine IP if running on device ex: http://192.168.1.5:8000 TIMEOUT: 10000, }; export const API_ENDPOINTS = { SPORTS: "/v1/api/sports", MATCHES_TODAY: "/v1/api/matches/today", + MATCH_DETAIL: (id: string) => `/v1/api/matches/${id}`, }; diff --git a/i18n/locales/en.json b/i18n/locales/en.json index d51bd25..437a9d7 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -28,5 +28,32 @@ "title": "My Profile", "name": "User Name", "settings": "Settings" + }, + "detail": { + "title": "Match Details", + "pending": "Pending", + "retry": "Retry", + "fetch_failed": "Failed to fetch details", + "not_found": "Match data not found", + "tabs": { + "info": "Details", + "h2h": "H2H", + "chat": "Chat" + }, + "info_card": { + "title": "Match Info", + "country": "Country/Region", + "league": "League", + "stage": "Stage", + "stadium": "Stadium", + "referee": "Referee" + }, + "score_table": { + "team": "Team", + "total": "Total" + }, + "halftime": "Half: {{score}}", + "empty_h2h": "No H2H data", + "empty_chat": "Chat is not available" } } \ No newline at end of file diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index e35417e..782b3f4 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -28,5 +28,32 @@ "title": "我的", "name": "用户名", "settings": "设置" + }, + "detail": { + "title": "比赛详情", + "pending": "待处理", + "retry": "重试", + "fetch_failed": "获取详情失败", + "not_found": "未找到比赛数据", + "tabs": { + "info": "详情", + "h2h": "交锋往绩", + "chat": "聊天" + }, + "info_card": { + "title": "比赛信息", + "country": "国家/地区", + "league": "赛事", + "stage": "阶段", + "stadium": "场馆", + "referee": "裁判" + }, + "score_table": { + "team": "球队", + "total": "Total" + }, + "halftime": "半场: {{score}}", + "empty_h2h": "暂无交锋数据", + "empty_chat": "聊天功能暂未开启" } } \ No newline at end of file diff --git a/lib/api.ts b/lib/api.ts index 1f95d9f..1de6ea5 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,5 +1,11 @@ import { API_CONFIG, API_ENDPOINTS } from "@/constants/api"; -import { ApiResponse, Match, Sport } from "@/types/api"; +import { + ApiListResponse, + ApiResponse, + Match, + MatchDetailData, + Sport, +} from "@/types/api"; import axios from "axios"; const apiClient = axios.create({ @@ -12,7 +18,7 @@ const apiClient = axios.create({ export const fetchSports = async (): Promise => { try { - const response = await apiClient.get>( + const response = await apiClient.get>>( API_ENDPOINTS.SPORTS ); if (response.data.code === 0) { @@ -28,7 +34,7 @@ export const fetchSports = async (): Promise => { export const fetchTodayMatches = async (sportId: number): Promise => { try { - const response = await apiClient.get>( + const response = await apiClient.get>>( API_ENDPOINTS.MATCHES_TODAY, { params: { sport_id: sportId }, @@ -44,3 +50,20 @@ export const fetchTodayMatches = async (sportId: number): Promise => { throw error; } }; + +export const fetchMatchDetail = async ( + id: string +): Promise => { + try { + const response = await apiClient.get>( + API_ENDPOINTS.MATCH_DETAIL(id) + ); + if (response.data.code === 0) { + return response.data.data; + } + throw new Error(response.data.message); + } catch (error) { + console.error("Fetch match detail error:", error); + throw error; + } +}; diff --git a/types/api.ts b/types/api.ts index 96f91e2..7f57b21 100644 --- a/types/api.ts +++ b/types/api.ts @@ -22,8 +22,56 @@ export interface Match { export interface ApiResponse { code: number; message: string; - data: { - list: T[]; - total: number; + data: T; +} + +export interface ApiListResponse { + list: T[]; + total: number; +} + +export interface MatchDetailData { + events: any[]; + match: { + ID: number; + CreatedAt: string; + UpdatedAt: string; + DeletedAt: string | null; + eventKey: string; + eventDate: string; + eventTime: string; + eventHomeTeam: string; + homeTeamKey: string; + homeTeamLogo: string; + eventAwayTeam: string; + awayTeamKey: string; + awayTeamLogo: string; + eventHalftimeResult: string; + eventFinalResult: string; + eventFtResult: string; + eventPenaltyResult: string; + eventStatus: string; + countryName: string; + leagueName: string; + leagueKey: string; + leagueRound: string; + leagueSeason: string; + eventLive: string; + eventStadium: string; + eventReferee: string; + eventCountryKey: string; + leagueLogo: string; + countryLogo: string; + eventHomeFormation: string; + eventAwayFormation: string; + fkStageKey: string; + stageName: string; + leagueGroup: string; + sportId: number; + eventQuarter: string; + eventSet: string; + eventType: string; + eventToss: string; + eventManOfMatch: string; }; }