实现详情页
This commit is contained in:
@@ -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 (
|
||||
<NavigationThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<NavigationThemeProvider
|
||||
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
|
||||
>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||
<Stack.Screen
|
||||
name="modal"
|
||||
options={{ presentation: "modal", title: "Modal" }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="match-detail/[id]"
|
||||
options={{
|
||||
animation: "slide_from_right",
|
||||
headerShown: true,
|
||||
headerTitle: "",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
||||
</NavigationThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
160
app/match-detail/[id].tsx
Normal file
160
app/match-detail/[id].tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [data, setData] = useState<MatchDetailData | null>(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 (
|
||||
<ThemedView style={styles.center}>
|
||||
<ActivityIndicator size="large" color={Colors[theme].tint} />
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<ThemedView style={styles.center}>
|
||||
<ThemedText>{error || t("detail.not_found")}</ThemedText>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={loadMatchDetail}>
|
||||
<ThemedText style={styles.retryText}>{t("detail.retry")}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case "info":
|
||||
return (
|
||||
<>
|
||||
<ScoreTable data={data} isDark={isDark} />
|
||||
<MatchInfoCard data={data} isDark={isDark} />
|
||||
</>
|
||||
);
|
||||
case "h2h":
|
||||
return (
|
||||
<View style={styles.emptyContent}>
|
||||
<ThemedText style={styles.emptyText}>
|
||||
{t("detail.empty_h2h")}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
case "chat":
|
||||
return (
|
||||
<View style={styles.emptyContent}>
|
||||
<ThemedText style={styles.emptyText}>
|
||||
{t("detail.empty_chat")}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 20 }}
|
||||
>
|
||||
<ScoreHeader data={data} isDark={isDark} topInset={insets.top} />
|
||||
<LeagueInfo data={data} isDark={isDark} />
|
||||
<MatchTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{renderTabContent()}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={[styles.card, { backgroundColor: cardBg, borderColor }]}>
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
{ backgroundColor: cardBg, borderColor, opacity: pressed ? 0.7 : 1 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View
|
||||
style={[
|
||||
@@ -52,7 +64,7 @@ export function MatchCard({ match }: MatchCardProps) {
|
||||
{match.meta && (
|
||||
<ThemedText style={styles.metaText}>{match.meta}</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
84
components/match-detail/league-info.tsx
Normal file
84
components/match-detail/league-info.tsx
Normal file
@@ -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 (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: bgColor, borderBottomColor: borderColor },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.left}>
|
||||
{match.leagueLogo ? (
|
||||
<Image source={{ uri: match.leagueLogo }} style={styles.leagueLogo} />
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.fallbackLogo,
|
||||
{ backgroundColor: isDark ? "#333" : "#F0F0F0" },
|
||||
]}
|
||||
>
|
||||
<IconSymbol
|
||||
name="trophy-outline"
|
||||
size={14}
|
||||
color={isDark ? "#AAA" : "#888"}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ThemedText style={styles.leagueName}>{match.leagueName}</ThemedText>
|
||||
</View>
|
||||
<IconSymbol
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={isDark ? "#666" : "#CCC"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
62
components/match-detail/match-info-card.tsx
Normal file
62
components/match-detail/match-info-card.tsx
Normal file
@@ -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 (
|
||||
<View style={[styles.container, { backgroundColor: bgColor }]}>
|
||||
<ThemedText style={styles.title}>
|
||||
{t("detail.info_card.title")}
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.content}>
|
||||
<ThemedText style={styles.leagueName}>{match.leagueName}</ThemedText>
|
||||
<ThemedText style={styles.dateTime}>
|
||||
{match.eventDate} • {match.eventTime}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
92
components/match-detail/match-tabs.tsx
Normal file
92
components/match-detail/match-tabs.tsx
Normal file
@@ -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 (
|
||||
<View style={[styles.container, { backgroundColor: containerBg }]}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.id}
|
||||
onPress={() => onTabChange(tab.id)}
|
||||
style={[
|
||||
styles.tabBtn,
|
||||
isActive && styles.activeTabBtn,
|
||||
isActive && {
|
||||
backgroundColor: isDark ? "rgba(255,255,255,0.05)" : "#FFF",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.tabText,
|
||||
isActive && styles.activeTabText,
|
||||
isActive && { color: "#FF9800" },
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
196
components/match-detail/score-header.tsx
Normal file
196
components/match-detail/score-header.tsx
Normal file
@@ -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 (
|
||||
<LinearGradient
|
||||
colors={["#1A2138", "#2A3C5B"]}
|
||||
style={[styles.container, { paddingTop: Math.max(topInset, 20) }]}
|
||||
>
|
||||
{/* Top Bar */}
|
||||
<View style={styles.topBar}>
|
||||
<View style={styles.leftContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.iconBtn}
|
||||
>
|
||||
<IconSymbol name="chevron-back" size={24} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusContainer}>
|
||||
<ThemedText style={styles.statusText}>
|
||||
{match.eventStatus || t("detail.pending")}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.rightContainer}>
|
||||
<TouchableOpacity style={styles.iconBtn}>
|
||||
<IconSymbol name="notifications-outline" size={22} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.iconBtn}>
|
||||
<IconSymbol name="star-outline" size={22} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Score Section */}
|
||||
<View style={styles.scoreRow}>
|
||||
<View style={styles.teamInfo}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={{ uri: match.homeTeamLogo }}
|
||||
style={styles.teamLogo}
|
||||
/>
|
||||
</View>
|
||||
<ThemedText style={styles.teamName} numberOfLines={2}>
|
||||
{match.eventHomeTeam}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.centerScore}>
|
||||
<View style={styles.scoreBox}>
|
||||
<ThemedText style={styles.scoreValue}>
|
||||
{match.eventFinalResult && match.eventFinalResult !== ""
|
||||
? match.eventFinalResult
|
||||
: "0 - 0"}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.fieldBtn}>
|
||||
<IconSymbol name="football-outline" size={16} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.teamInfo}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
source={{ uri: match.awayTeamLogo }}
|
||||
style={styles.teamLogo}
|
||||
/>
|
||||
</View>
|
||||
<ThemedText style={styles.teamName} numberOfLines={2}>
|
||||
{match.eventAwayTeam}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
132
components/match-detail/score-table.tsx
Normal file
132
components/match-detail/score-table.tsx
Normal file
@@ -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 (
|
||||
<View style={[styles.container, { backgroundColor: bgColor }]}>
|
||||
<View style={styles.header}>
|
||||
{headers.map((h, i) => (
|
||||
<ThemedText
|
||||
key={h}
|
||||
style={[
|
||||
styles.headerText,
|
||||
{
|
||||
color: headerTextColor,
|
||||
flex: i === 0 ? 3 : 1,
|
||||
textAlign: i === 0 ? "left" : "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{h}
|
||||
</ThemedText>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{rows.map((row, idx) => (
|
||||
<View key={idx} style={[styles.row, idx === 0 && styles.rowBorder]}>
|
||||
<View style={styles.teamCell}>
|
||||
<Image source={{ uri: row.logo }} style={styles.teamLogo} />
|
||||
</View>
|
||||
<ThemedText style={styles.cellText}>{row.total}</ThemedText>
|
||||
<ThemedText style={styles.cellText}>{row.q1}</ThemedText>
|
||||
<ThemedText style={styles.cellText}>{row.q2}</ThemedText>
|
||||
<ThemedText style={styles.cellText}>{row.q3}</ThemedText>
|
||||
<ThemedText style={styles.cellText}>{row.q4}</ThemedText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -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}`,
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "聊天功能暂未开启"
|
||||
}
|
||||
}
|
||||
29
lib/api.ts
29
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<Sport[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<Sport>>(
|
||||
const response = await apiClient.get<ApiResponse<ApiListResponse<Sport>>>(
|
||||
API_ENDPOINTS.SPORTS
|
||||
);
|
||||
if (response.data.code === 0) {
|
||||
@@ -28,7 +34,7 @@ export const fetchSports = async (): Promise<Sport[]> => {
|
||||
|
||||
export const fetchTodayMatches = async (sportId: number): Promise<Match[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<Match>>(
|
||||
const response = await apiClient.get<ApiResponse<ApiListResponse<Match>>>(
|
||||
API_ENDPOINTS.MATCHES_TODAY,
|
||||
{
|
||||
params: { sport_id: sportId },
|
||||
@@ -44,3 +50,20 @@ export const fetchTodayMatches = async (sportId: number): Promise<Match[]> => {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchMatchDetail = async (
|
||||
id: string
|
||||
): Promise<MatchDetailData> => {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<MatchDetailData>>(
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
50
types/api.ts
50
types/api.ts
@@ -22,8 +22,56 @@ export interface Match {
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ApiListResponse<T> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user