添加网球详情

This commit is contained in:
yuchenglong
2026-01-22 18:48:39 +08:00
parent a279083252
commit c63753e631
16 changed files with 1142 additions and 32 deletions

View File

@@ -5,6 +5,9 @@ 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 { TennisPowerGraph } from "@/components/live-detail/tennis-power-graph";
import { TennisScoreboard } from "@/components/live-detail/tennis-scoreboard";
import { TennisStatsCard } from "@/components/live-detail/tennis-stats-card";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { Colors } from "@/constants/theme";
@@ -99,14 +102,37 @@ export default function LiveDetailScreen() {
const renderTabContent = () => {
const numericSportId = parseInt(sport_id || "1");
// Tennis Check
const isTennis =
!!match.event_first_player ||
(match.league_name &&
/ATP|WTA|ITF|Challenger/i.test(match.league_name || ""));
switch (activeTab) {
case "stats":
return <StatsCard match={match} isDark={isDark} />;
return !isTennis ? (
<StatsCard match={match} isDark={isDark} />
) : (
<View style={styles.center}>
<ThemedText style={{ opacity: 0.5 }}>
{t("detail.empty_stats")}
</ThemedText>
</View>
);
case "odds":
return (
<OddsCard match={match} isDark={isDark} sportId={numericSportId} />
);
case "detail":
if (isTennis) {
return (
<>
<TennisScoreboard match={match} isDark={isDark} />
<TennisStatsCard match={match} isDark={isDark} />
<TennisPowerGraph match={match} isDark={isDark} />
</>
);
}
return (
<>
<OddsCard match={match} isDark={isDark} sportId={numericSportId} />

View File

@@ -151,9 +151,24 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
};
const lastServerMatchRef = React.useRef(
`${match.event_status}-${match.event_time}`
`${match.event_status}-${match.event_time}`,
);
// Tennis Logic
const isTennis =
!!match.event_first_player ||
(match.league_name &&
/ATP|WTA|ITF|Challenger/i.test(match.league_name || ""));
const homeName = isTennis ? match.event_first_player : match.event_home_team;
const awayName = isTennis ? match.event_second_player : match.event_away_team;
const homeLogo = isTennis
? match.event_first_player_logo
: match.home_team_logo;
const awayLogo = isTennis
? match.event_second_player_logo
: match.away_team_logo;
// 服务器时间同步
React.useEffect(() => {
const currentKey = `${match.event_status}-${match.event_time}`;
@@ -251,13 +266,13 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
</TouchableOpacity>
<View style={styles.logoContainer}>
<Image
source={{ uri: match.home_team_logo }}
style={styles.teamLogo}
source={{ uri: homeLogo }}
style={[styles.teamLogo, isTennis && styles.tennisAvatar]}
/>
</View>
</View>
<ThemedText style={styles.teamName} numberOfLines={2}>
{match.event_home_team}
{homeName}
</ThemedText>
</View>
@@ -274,10 +289,12 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
{match.event_status}
</ThemedText>
)}
{!isTennis && (
<ThemedText style={styles.timeText}>{displayTime}</ThemedText>
)}
</View>
{match.goalscorers && match.goalscorers.length > 0 && (
{!isTennis && match.goalscorers && match.goalscorers.length > 0 && (
<View style={styles.lastGoalContainer}>
<IconSymbol name="football-outline" size={12} color="#FFF" />
<ThemedText style={styles.lastGoalText}>
@@ -286,14 +303,24 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
</ThemedText>
</View>
)}
{isTennis && match.event_serve && (
<View style={styles.serveIndicator}>
<IconSymbol name="tennisball" size={12} color="#CCFF00" />
<ThemedText style={styles.serveText}>
{match.event_serve === "First Player" ? homeName : awayName}{" "}
Serving
</ThemedText>
</View>
)}
</View>
<View style={styles.teamInfo}>
<View style={styles.teamLogoRow}>
<View style={styles.logoContainer}>
<Image
source={{ uri: match.away_team_logo }}
style={styles.teamLogo}
source={{ uri: awayLogo }}
style={[styles.teamLogo, isTennis && styles.tennisAvatar]}
/>
</View>
<TouchableOpacity
@@ -309,7 +336,7 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
</TouchableOpacity>
</View>
<ThemedText style={styles.teamName} numberOfLines={2}>
{match.event_away_team}
{awayName}
</ThemedText>
</View>
</View>
@@ -442,4 +469,21 @@ const styles = StyleSheet.create({
fontSize: 10,
marginLeft: 4,
},
serveIndicator: {
flexDirection: "row",
alignItems: "center",
marginTop: 6,
backgroundColor: "rgba(0,0,0,0.3)",
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
gap: 4,
},
serveText: {
color: "#FFF",
fontSize: 10,
},
tennisAvatar: {
borderRadius: 30,
},
});

View File

@@ -87,12 +87,13 @@ export function StatsCard({ match, isDark }: StatsCardProps) {
const progressPercent = Math.min(100, (totalSeconds / (90 * 60)) * 100);
// 从 statistics 中提取数据
const stats = match.statistics || [];
const stats = (match.statistics || []) as any[];
const getStatValue = (type: string) => {
const stat = stats.find((s) => s.type === type);
return stat
? { home: stat.home, away: stat.away }
: { home: "0", away: "0" };
if (stat && stat.home) {
return { home: stat.home, away: stat.away };
}
return { home: "0", away: "0" };
};
const possession = getStatValue("Ball Possession");

View File

@@ -0,0 +1,332 @@
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { LiveScoreMatch } from "@/types/api";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Image, ScrollView, StyleSheet, View } from "react-native";
interface TennisPowerGraphProps {
match: LiveScoreMatch;
isDark: boolean;
}
export function TennisPowerGraph({ match, isDark }: TennisPowerGraphProps) {
const { t } = useTranslation();
const pointData = match.pointbypoint;
if (!pointData || pointData.length === 0) {
return null;
}
// Grouped Data by Game
// Structure: { gameIndex: number, score: string, set: string, points: {winner: 1|2}[] }
const gameData = useMemo(() => {
return pointData.map((game) => {
const points = game.points || [];
const bars: { winner: 1 | 2 }[] = [];
let prevP1Score = 0;
let prevP2Score = 0;
const parseScore = (s: string) => {
if (s === "A") return 45;
return parseInt(s) || 0;
};
points.forEach((point: { score: string }) => {
const parts = point.score.split("-").map((s) => s.trim());
if (parts.length !== 2) return;
const p1 = parseScore(parts[0]);
const p2 = parseScore(parts[1]);
let winner: 1 | 2 | null = null;
if (p1 > prevP1Score && p2 === prevP2Score) winner = 1;
else if (p2 > prevP2Score && p1 === prevP1Score) winner = 2;
else if (p1 === 45 && prevP1Score === 40) winner = 1;
else if (p2 === 45 && prevP2Score === 40) winner = 2;
else if (p1 === 40 && prevP1Score === 45) winner = 2;
else if (p2 === 40 && prevP2Score === 45) winner = 1;
else if (
points.length === 1 &&
prevP1Score === 0 &&
prevP2Score === 0
) {
if (p1 > 0) winner = 1;
if (p2 > 0) winner = 2;
} else if (p1 > prevP1Score || (p2 < prevP2Score && prevP2Score === 45))
winner = 1;
else if (p2 > prevP2Score || (p1 < prevP1Score && prevP1Score === 45))
winner = 2;
if (winner) {
bars.push({ winner });
}
prevP1Score = p1;
prevP2Score = p2;
});
// Find if this game ended a set? Or just carry current set score/game score.
// `game.score` is e.g. "1 - 0" (Set Score or Game Score within set?)
// Usually `pointbypoint` item has `score` field which is the score in Games for that Set.
return {
gameIndex: parseInt(game.number_game),
setNumber: game.set_number, // "Set 1"
score: game.score, // "6 - 4" or "1 - 0"
serveWinner: game.serve_winner, // "First Player" or "Second Player"
bars,
};
});
}, [pointData]);
if (gameData.length === 0) return null;
// Render logic
// Left side: Avatars
// Right side: ScrollView of Game Blocks
// Avatars
const p1Logo = match.event_first_player_logo;
const p2Logo = match.event_second_player_logo;
return (
<ThemedView
style={[
styles.container,
{
backgroundColor: isDark ? "#1E1E20" : "#FFF",
borderRadius: 12,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
]}
>
<ThemedText style={[styles.title, { color: isDark ? "#FFF" : "#000" }]}>
{t("detail.power_graph")}
</ThemedText>
<View style={styles.contentRow}>
{/* Sidebar - Avatars */}
<View style={styles.sidebar}>
<View style={{ flex: 1 }} /> {/* Spacer top */}
<View style={styles.avatarContainer}>
{p1Logo ? (
<Image source={{ uri: p1Logo }} style={styles.avatar} />
) : (
<View style={[styles.avatar, { backgroundColor: "#ccc" }]} />
)}
</View>
<View style={{ height: 10 }} />{" "}
{/* Gap corresponding to center line if needed, but styling is easier with even spacing */}
<View style={styles.avatarContainer}>
{p2Logo ? (
<Image source={{ uri: p2Logo }} style={styles.avatar} />
) : (
<View style={[styles.avatar, { backgroundColor: "#ccc" }]} />
)}
</View>
<View style={{ flex: 1 }} /> {/* Spacer bottom */}
</View>
{/* Scrollable Graph */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.graphScroll}
>
{gameData.map((game, i) => {
// Updated to match screenshot: Split background (Blue top, Yellow bottom)
const topBg = isDark ? "rgba(33, 150, 243, 0.15)" : "#E3F2FD";
const botBg = isDark ? "rgba(255, 193, 7, 0.15)" : "#FFF8E1";
return (
<View key={i} style={styles.gameBlock}>
{/* Top Score Label if needed */}
<View style={styles.gameTopLabel} />
{/* Bars Area */}
<View style={styles.barsArea}>
{/* Backgrounds */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
backgroundColor: topBg,
}}
/>
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: "50%",
backgroundColor: botBg,
}}
/>
{/* Center Line */}
<View
style={[
styles.centerLine,
{ backgroundColor: "#FFF", height: 2, zIndex: 1 },
]}
/>
{game.bars.map((bar, bi) => (
<View key={bi} style={[styles.barColumn, { zIndex: 2 }]}>
{/* P1 Bar (Top) */}
<View
style={[
styles.barSegment,
{
height: 24,
justifyContent: "flex-end",
},
]}
>
{bar.winner === 1 && (
<View
style={[
styles.activeBar,
{
backgroundColor: "#2196F3",
height:
bi === game.bars.length - 1 ? "100%" : "60%",
},
]}
/>
)}
</View>
{/* P2 Bar (Bottom) */}
<View
style={[
styles.barSegment,
{
height: 24,
justifyContent: "flex-start",
},
]}
>
{bar.winner === 2 && (
<View
style={[
styles.activeBar,
{
backgroundColor: "#FFC107",
height:
bi === game.bars.length - 1 ? "100%" : "60%",
},
]}
/>
)}
</View>
</View>
))}
</View>
{/* Bottom Game Number */}
<View style={styles.gameBottomLabel}>
<ThemedText style={styles.gameNumberText}>
{game.gameIndex}
</ThemedText>
</View>
</View>
);
})}
</ScrollView>
</View>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
margin: 16,
padding: 16,
borderRadius: 12,
},
title: {
fontSize: 16,
fontWeight: "bold",
marginBottom: 20,
},
contentRow: {
flexDirection: "row",
height: 120, // Total height for graph area
},
sidebar: {
width: 40,
alignItems: "center",
justifyContent: "center", // Center avatars vertically relative to bars
paddingBottom: 20, // Adjust for game number row
},
avatarContainer: {
width: 32,
height: 32,
borderRadius: 16,
overflow: "hidden",
borderWidth: 1,
borderColor: "#333",
},
avatar: {
width: "100%",
height: "100%",
},
graphScroll: {
flex: 1,
marginLeft: 8,
},
gameBlock: {
paddingHorizontal: 4,
marginRight: 2,
minWidth: 40,
borderRadius: 4,
},
gameTopLabel: {
height: 20,
alignItems: "center",
justifyContent: "center",
},
barsArea: {
flexDirection: "row",
alignItems: "center",
height: 60, // Central bar area
position: "relative",
justifyContent: "center",
},
centerLine: {
position: "absolute",
width: "100%",
height: 1,
top: 30, // Middle of 60
},
barColumn: {
width: 6,
marginHorizontal: 1,
height: "100%",
justifyContent: "center",
},
barSegment: {
width: "100%",
},
activeBar: {
width: "100%",
borderRadius: 2,
},
gameBottomLabel: {
height: 20,
alignItems: "center",
justifyContent: "center",
},
gameNumberText: {
fontSize: 10,
opacity: 0.6,
},
});

View File

@@ -0,0 +1,244 @@
import { ThemedText } from "@/components/themed-text";
import { LiveScoreMatch } from "@/types/api";
import { Image } from "expo-image";
import React from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";
interface TennisScoreboardProps {
match: LiveScoreMatch;
isDark: boolean;
}
export function TennisScoreboard({ match, isDark }: TennisScoreboardProps) {
const { t } = useTranslation();
// Tennis Logic
const isTennis =
!!match.event_first_player ||
(match.league_name &&
/ATP|WTA|ITF|Challenger/i.test(match.league_name || ""));
if (!isTennis) return null;
const homeName = isTennis ? match.event_first_player : match.event_home_team;
const awayName = isTennis ? match.event_second_player : match.event_away_team;
const homeLogo = isTennis
? match.event_first_player_logo
: match.home_team_logo;
const awayLogo = isTennis
? match.event_second_player_logo
: match.away_team_logo;
const tennisScores = React.useMemo(() => {
if (!match.scores) return [];
try {
const s = match.scores;
return Array.isArray(s) ? s : [];
} catch {
return [];
}
}, [match.scores]);
const gamePoints = React.useMemo(() => {
if (!match.event_game_result) return { p1: "0", p2: "0" };
const parts = match.event_game_result.split("-");
if (parts.length === 2) {
return { p1: parts[0].trim(), p2: parts[1].trim() };
}
return { p1: "0", p2: "0" };
}, [match.event_game_result]);
const totalSets = React.useMemo(() => {
if (!match.event_final_result) return { p1: 0, p2: 0 };
const parts = match.event_final_result.split("-");
if (parts.length === 2) {
return {
p1: parseInt(parts[0].trim()) || 0,
p2: parseInt(parts[1].trim()) || 0,
};
}
return { p1: 0, p2: 0 };
}, [match.event_final_result]);
const bgColor = isDark ? "#1E1E20" : "#FFF";
const textColor = isDark ? "#FFF" : "#000";
const headerColor = isDark ? "#888" : "#666";
const borderColor = isDark ? "#333" : "#F0F0F0";
return (
<View style={[styles.tennisBoardContainer, { backgroundColor: bgColor }]}>
<View style={styles.header}>
<ThemedText
style={{ fontSize: 16, fontWeight: "700", marginBottom: 12 }}
>
{t("detail.scoreboard", "Scoreboard")}
</ThemedText>
</View>
<View
style={[styles.tennisBoardHeader, { borderBottomColor: borderColor }]}
>
<ThemedText style={[styles.boardHeaderLabel, { color: headerColor }]}>
Player
</ThemedText>
<ThemedText
style={[
styles.boardHeaderLabel,
styles.boardColCenter,
{ color: "#4CAF50" },
]}
>
Game
</ThemedText>
{[1, 2, 3, 4, 5].map((i) => (
<ThemedText
key={i}
style={[
styles.boardHeaderLabel,
styles.boardColCenter,
{
color:
i.toString() === match.event_status?.replace("Set ", "")
? "#4CAF50"
: headerColor,
},
]}
>
S{i}
</ThemedText>
))}
<ThemedText
style={[
styles.boardHeaderLabel,
styles.boardColCenter,
{ color: headerColor },
]}
>
Sets
</ThemedText>
</View>
<View style={styles.tennisRow}>
<View style={styles.boardPlayerCol}>
<Image source={{ uri: homeLogo }} style={styles.miniAvatar} />
</View>
<ThemedText
style={[styles.boardVal, styles.boardColCenter, { color: "#4CAF50" }]}
>
{gamePoints.p1}
</ThemedText>
{[1, 2, 3, 4, 5].map((i) => {
const setScore = tennisScores.find((s: any) => s.score_set == i);
return (
<ThemedText
key={i}
style={[
styles.boardVal,
styles.boardColCenter,
{
color:
i.toString() === match.event_status?.replace("Set ", "")
? "#4CAF50"
: textColor,
},
]}
>
{setScore ? setScore.score_first : "-"}
</ThemedText>
);
})}
<ThemedText
style={[styles.boardVal, styles.boardColCenter, { color: textColor }]}
>
{totalSets.p1}
</ThemedText>
</View>
<View style={styles.tennisRow}>
<View style={styles.boardPlayerCol}>
<Image source={{ uri: awayLogo }} style={styles.miniAvatar} />
</View>
<ThemedText
style={[styles.boardVal, styles.boardColCenter, { color: "#4CAF50" }]}
>
{gamePoints.p2}
</ThemedText>
{[1, 2, 3, 4, 5].map((i) => {
const setScore = tennisScores.find((s: any) => s.score_set == i);
return (
<ThemedText
key={i}
style={[
styles.boardVal,
styles.boardColCenter,
{
color:
i.toString() === match.event_status?.replace("Set ", "")
? "#4CAF50"
: textColor,
},
]}
>
{setScore ? setScore.score_second : "-"}
</ThemedText>
);
})}
<ThemedText
style={[styles.boardVal, styles.boardColCenter, { color: textColor }]}
>
{totalSets.p2}
</ThemedText>
</View>
</View>
);
}
const styles = StyleSheet.create({
tennisBoardContainer: {
marginHorizontal: 16,
borderRadius: 16,
padding: 16,
marginTop: 12,
marginBottom: 16,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
},
tennisBoardHeader: {
flexDirection: "row",
alignItems: "center",
borderBottomWidth: 1,
paddingBottom: 8,
marginBottom: 8,
},
boardHeaderLabel: {
fontSize: 12,
fontWeight: "500",
},
boardPlayerCol: {
width: 40,
alignItems: "center",
},
boardColCenter: {
flex: 1,
textAlign: "center",
},
tennisRow: {
flexDirection: "row",
alignItems: "center",
height: 36,
},
miniAvatar: {
width: 28,
height: 28,
borderRadius: 14,
},
boardVal: {
fontSize: 14,
fontWeight: "600",
},
});

View File

@@ -0,0 +1,408 @@
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";
import Svg, { Circle, G } from "react-native-svg";
interface TennisStatsCardProps {
match: LiveScoreMatch;
isDark: boolean;
}
// Helper for Circular Progress
// Updated to match screenshot: Labels on top, circle in middle, values on sides.
const CircularStat = ({
value1,
value2,
label,
color1 = "#2196F3", // Blue
color2 = "#FFC107", // Gold
size = 50,
strokeWidth = 5,
isDark,
}: {
value1: string; // "67%" or "67"
value2: string;
label: string;
color1?: string;
color2?: string;
size?: number;
strokeWidth?: number;
isDark: boolean;
}) => {
const parse = (v: string) => parseFloat(v.replace("%", "")) || 0;
const v1 = parse(value1);
const v2 = parse(value2);
// Radius
const r = (size - strokeWidth) / 2;
const c = size / 2;
const circumference = 2 * Math.PI * r;
// Inner radius
const innerR = r - strokeWidth - 2; // small gap
const innerCircumference = 2 * Math.PI * innerR;
// Colors for text need to be readable on white background if card is always white
// Screenshot shows white card on dark background.
// BUT the text "First Serve %" is dark/grey.
// The values 67.4 are Blue/Gold.
// The circle tracks are light grey maybe.
const trackColor = isDark ? "#333" : "#E0E0E0";
const labelColor = isDark ? "#AAA" : "#666";
return (
<View style={styles.circularStatContainer}>
{/* Label centered */}
<ThemedText
style={[styles.circularLabel, { color: labelColor }]}
numberOfLines={2}
>
{label}
</ThemedText>
<View style={styles.circularRow}>
{/* Left Value P1 */}
<ThemedText style={[styles.statValueSide, { color: color1 }]}>
{value1}
</ThemedText>
{/* Circle Graphic */}
<View style={{ width: size, height: size, marginHorizontal: 6 }}>
<Svg width={size} height={size}>
<G rotation="-90" origin={`${c}, ${c}`}>
{/* Outer Track */}
<Circle
cx={c}
cy={c}
r={r}
stroke={trackColor}
strokeWidth={strokeWidth}
fill="transparent"
/>
{/* P1 Progress (Outer) */}
<Circle
cx={c}
cy={c}
r={r}
stroke={color1}
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={circumference}
strokeDashoffset={circumference - (v1 / 100) * circumference}
strokeLinecap="round"
/>
{/* Inner Track */}
<Circle
cx={c}
cy={c}
r={innerR}
stroke={trackColor}
strokeWidth={strokeWidth}
fill="transparent"
/>
{/* P2 Progress (Inner) */}
<Circle
cx={c}
cy={c}
r={innerR}
stroke={color2}
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={innerCircumference}
strokeDashoffset={
innerCircumference - (v2 / 100) * innerCircumference
}
strokeLinecap="round"
/>
</G>
</Svg>
</View>
{/* Right Value P2 */}
<ThemedText style={[styles.statValueSide, { color: color2 }]}>
{value2}
</ThemedText>
</View>
</View>
);
};
// Helper for Bar Stat
const BarStat = ({
label,
value1,
value2,
color1 = "#2196F3",
color2 = "#FFC107",
isDark,
}: {
label: string;
value1: string;
value2: string;
color1?: string;
color2?: string;
isDark: boolean;
}) => {
// Parse to number for bars
const v1 = parseFloat(value1) || 0;
const v2 = parseFloat(value2) || 0;
const total = v1 + v2 || 1;
const p1 = (v1 / total) * 100;
const p2 = (v2 / total) * 100;
return (
<View style={styles.barStatContainer}>
<View style={styles.barHeader}>
<View style={[styles.pill, { backgroundColor: color1 }]}>
<ThemedText style={styles.pillText}>{value1}</ThemedText>
</View>
<ThemedText style={styles.barLabel}>{label}</ThemedText>
<View
style={[
styles.pill,
{ backgroundColor: color2, borderColor: color2, borderWidth: 1 },
]}
>
<ThemedText style={[styles.pillText, { color: "#000" }]}>
{value2}
</ThemedText>
</View>
</View>
<View style={styles.barTrack}>
<View
style={[
styles.barFill,
{
flex: v1,
backgroundColor: color1,
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
},
]}
/>
<View style={{ width: 4 }} />
<View
style={[
styles.barFill,
{
flex: v2,
backgroundColor: color2,
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
},
]}
/>
</View>
</View>
);
};
export function TennisStatsCard({ match, isDark }: TennisStatsCardProps) {
const { t } = useTranslation();
const statistics = match.statistics;
if (!statistics || !Array.isArray(statistics) || statistics.length === 0) {
return null;
}
// Check structure. If it's the Football type (home/away keys), we shouldn't be here (LiveDetail logic checks sport).
// But safely handle:
const isTennisFormat =
"player_key" in statistics[0] || "name" in statistics[0];
if (!isTennisFormat) return null;
// Group by name
// Map Name -> { p1Value: string, p2Value: string }
const statsMap: Record<string, { p1: string; p2: string; type?: string }> =
{};
// Assume P1 is first_player_key
// Using home_team_key as fallback/primary since first_player_key isn't in type definition
// In many APIs, for tennis, home_team_key === first_player_key
const p1Key = match.home_team_key;
// In generic response, keys might be strings vs numbers.
// Let's rely on finding two entries for same "name" and "period".
// Or just iterate.
(statistics as any[]).forEach((stat) => {
// Filter for 'match' period (total)
if (stat.period !== "match") return;
const name = stat.name;
if (!statsMap[name]) statsMap[name] = { p1: "0", p2: "0", type: stat.type };
// Assign to P1 or P2
// If we know p1Key
if (p1Key && stat.player_key == p1Key) {
statsMap[name].p1 = stat.value;
} else {
statsMap[name].p2 = stat.value;
}
});
// Define intended stats to show
// Top (Circles):
// "1st serve percentage" (1st Serve %)
// "1st serve points won" (1st Serve Win %)
// "Break Points Converted" -> "Break Success %" (Screenshot: 破发成功率)
// Or match keys exactly: "1st serve percentage", "1st serve points won", "Break Points Converted"
// Middle (Bars):
// "Aces" (Screenshot: 发球得分? Or implies Aces).
// "Double Faults" (Screenshot: 双误)
// "Service Points Won"
const circleStats = [
{ key: "1st serve percentage", label: t("detail.stats.first_serve") },
{
key: "1st serve points won",
label: t("detail.stats.first_serve_points"),
},
{ key: "Break Points Converted", label: t("detail.stats.break_points") },
];
const barStats = [
{ key: "Aces", label: t("detail.stats.aces") },
{ key: "Double Faults", label: t("detail.stats.double_faults") },
];
// Check if any of the target stats actually exist in the map
const hasAnyStat = [...circleStats, ...barStats].some((s) => statsMap[s.key]);
if (!hasAnyStat) return null;
return (
<ThemedView
style={[
styles.container,
{
backgroundColor: isDark ? "#1E1E20" : "#FFF",
borderRadius: 12,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
]}
>
<ThemedText style={[styles.title, { color: isDark ? "#FFF" : "#000" }]}>
{t("detail.statistics")}
</ThemedText>
<View style={styles.circlesRow}>
{circleStats.map((s) =>
statsMap[s.key] ? (
<CircularStat
key={s.key}
label={s.label}
value1={statsMap[s.key].p1}
value2={statsMap[s.key].p2}
size={50}
isDark={false}
/>
) : null,
)}
</View>
<View style={styles.barsColumn}>
{barStats.map((s) =>
statsMap[s.key] ? (
<BarStat
key={s.key}
label={s.label}
value1={statsMap[s.key].p1}
value2={statsMap[s.key].p2}
isDark={isDark}
/>
) : null,
)}
</View>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
margin: 16,
padding: 16,
borderRadius: 12,
},
title: {
fontSize: 16,
fontWeight: "bold",
marginBottom: 20,
},
circlesRow: {
flexDirection: "row",
justifyContent: "space-around",
marginBottom: 24,
},
circularStatContainer: {
alignItems: "center",
width: "30%",
},
circularLabel: {
fontSize: 12,
textAlign: "center",
opacity: 0.7,
height: 32, // Fixed height for alignment
},
circularRow: {
flexDirection: "row",
alignItems: "center",
},
statValueSide: {
fontSize: 12,
fontWeight: "bold",
},
statValue: {
fontSize: 12,
fontWeight: "600",
},
barsColumn: {
gap: 16,
},
barStatContainer: {
marginBottom: 8,
},
barHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
},
pill: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
minWidth: 40,
alignItems: "center",
},
pillText: {
color: "white",
fontWeight: "bold",
fontSize: 12,
},
barLabel: {
opacity: 0.8,
},
barTrack: {
flexDirection: "row",
height: 6,
backgroundColor: "#333",
borderRadius: 3,
overflow: "hidden",
},
barFill: {
height: "100%",
},
});

View File

@@ -262,14 +262,15 @@ export function MatchCard({
return { home: "", away: "" };
}
const corners = liveDetail.statistics.find((s) => s.type === "Corners");
// Cast statistics to any[] to avoid union type issues when accessing 'home'/'away' properties
const stats = liveDetail.statistics as any[];
const corners = stats.find((s) => s.type === "Corners");
if (corners) {
return { home: corners.home, away: corners.away };
}
const dangerousAttacks = liveDetail.statistics.find(
(s) => s.type === "Dangerous Attacks",
);
const dangerousAttacks = stats.find((s) => s.type === "Dangerous Attacks");
if (dangerousAttacks) {
return { home: dangerousAttacks.home, away: dangerousAttacks.away };
}

View File

@@ -91,6 +91,18 @@
"retry": "Retry",
"fetch_failed": "Failed to fetch details",
"not_found": "Match data not found",
"scoreboard": "Scoreboard",
"power_graph": "Power Graph",
"statistics": "Statistics",
"stats": {
"first_serve": "1st Serve %",
"first_serve_points": "1st Serve Pts Won",
"break_points": "Break Success %",
"aces": "Aces",
"double_faults": "Double Faults",
"service_points": "Service Points Won",
"total_points": "Total Points Won"
},
"tabs": {
"info": "Details",
"stats": "Statistics",

View File

@@ -96,6 +96,7 @@
"h2h": "आमने-सामने",
"chat": "चैट"
},
"scoreboard": "स्कोरबोर्ड",
"info_card": {
"title": "मैच जानकारी",
"country": "देश/क्षेत्र",

View File

@@ -87,6 +87,7 @@
"retry": "Coba lagi",
"fetch_failed": "Gagal mengambil data",
"not_found": "Data pertandingan tidak ditemukan",
"scoreboard": "Papan Skor",
"tabs": {
"info": "Detail",
"stats": "Statistik",

View File

@@ -87,6 +87,7 @@
"retry": "Cuba semula",
"fetch_failed": "Gagal mendapatkan maklumat",
"not_found": "Data perlawanan tidak ditemui",
"scoreboard": "Papan Skor",
"tabs": {
"info": "Maklumat",
"stats": "Statistik",

View File

@@ -87,6 +87,7 @@
"retry": "ลองอีกครั้ง",
"fetch_failed": "ไม่สามารถดึงข้อมูลได้",
"not_found": "ไม่พบข้อมูลการแข่งขัน",
"scoreboard": "สกอร์บอร์ด",
"tabs": {
"info": "รายละเอียด",
"stats": "สถิติ",

View File

@@ -87,6 +87,7 @@
"retry": "Thử lại",
"fetch_failed": "Không thể tải dữ liệu",
"not_found": "Không tìm thấy dữ liệu trận đấu",
"scoreboard": "Bảng điểm",
"tabs": {
"info": "Thông tin",
"stats": "Thống kê",

View File

@@ -91,6 +91,18 @@
"retry": "重试",
"fetch_failed": "获取详情失败",
"not_found": "未找到比赛数据",
"scoreboard": "记分牌",
"power_graph": "功率图",
"statistics": "统计数据",
"stats": {
"first_serve": "一发成功率",
"first_serve_points": "一发得分率",
"break_points": "破发成功率",
"aces": "Ace球",
"double_faults": "双误",
"service_points": "发球得分",
"total_points": "总得分"
},
"tabs": {
"info": "详情",
"stats": "统计数据",

View File

@@ -214,11 +214,11 @@ export const fetchLiveScore = async (
leagueId?: number,
timezone?: string,
): Promise<LiveScoreMatch[]> => {
// console.log("Fetching live scores with params:", {
// sportId,
// leagueId,
// timezone,
// });
console.log("Fetching live scores with params:", {
sportId,
leagueId,
timezone,
});
try {
const params: { sport_id: number; league_id?: number; timezone?: string } =
{

View File

@@ -58,6 +58,7 @@ export interface LiveScoreMatch {
away_team_logo: string;
event_final_result: string;
event_halftime_result: string;
event_game_result?: string;
event_status: string;
event_live: string;
league_key: number;
@@ -113,10 +114,34 @@ export interface LiveScoreMatch {
score: string;
}[];
lineups?: unknown;
statistics?: {
statistics?:
| {
type: string;
home: string;
away: string;
}[]
| {
name: string;
period: string;
player_key: number;
value: string;
type?: string;
total?: number | null;
won?: number | null;
}[];
pointbypoint?: {
number_game: string;
player_served: string;
score: string;
serve_winner: string;
set_number: string;
points: {
break_point: null | string;
match_point: null | string;
number_point: string;
score: string;
set_point: null | string;
}[];
}[];
}