添加网球详情

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

@@ -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%",
},
});