Files
physical-expo/components/live-detail/stats-card.tsx

859 lines
23 KiB
TypeScript

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,
LayoutChangeEvent,
Modal,
Pressable,
StyleSheet,
Switch,
TouchableOpacity,
View,
} from "react-native";
import Svg, {
Defs,
Path,
Stop,
LinearGradient as SvgLinearGradient,
} from "react-native-svg";
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);
const [chartWidth, setChartWidth] = useState(0);
const onChartLayout = (event: LayoutChangeEvent) => {
setChartWidth(event.nativeEvent.layout.width);
};
// 实时时间与进度计算
const getInitialMinutes = () => {
const statusIdx = parseInt(match.event_status) || 0;
const timeParts = match.event_time?.split(":") || [];
const tMin = parseInt(timeParts[0]) || 0;
const tSec = parseInt(timeParts[1]) || 0;
if (!isNaN(statusIdx) && /^\d+$/.test(match.event_status)) {
return { min: Math.max(0, statusIdx - tMin), sec: tSec };
}
return { min: tMin, sec: tSec };
};
const initial = getInitialMinutes();
const [minutes, setMinutes] = useState(initial.min);
const [seconds, setSeconds] = useState(initial.sec);
React.useEffect(() => {
const updated = getInitialMinutes();
setMinutes(updated.min);
setSeconds(updated.sec);
}, [match.event_time, match.event_status]);
React.useEffect(() => {
const statusStr = match.event_status?.toLowerCase() || "";
const isLive =
String(match.event_live) === "1" ||
statusStr.includes("live") ||
(parseInt(statusStr) > 0 && parseInt(statusStr) < 120);
if (!isLive || statusStr.includes("ht")) return;
const interval = setInterval(() => {
setSeconds((s) => {
if (s >= 59) {
setMinutes((m) => m + 1);
return 0;
}
return s + 1;
});
}, 1000);
return () => clearInterval(interval);
}, [match.event_key, match.event_live, match.event_status]);
const displayTime = `${minutes}:${seconds.toString().padStart(2, "0")}`;
const totalSeconds = minutes * 60 + seconds;
const progressPercent = Math.min(100, (totalSeconds / (90 * 60)) * 100);
// 从 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 = React.useMemo(() => {
const data = [];
let h = 10;
let a = 10;
for (let i = 0; i < 90; i++) {
h = Math.max(0, Math.min(40, h + (Math.random() - 0.5) * 10));
a = Math.max(0, Math.min(40, a + (Math.random() - 0.5) * 10));
data.push({
time: i,
home: h,
away: a,
});
}
return data;
}, [match.event_key]);
const getWavePath = (
data: any[],
type: "home" | "away",
width: number,
height: number,
) => {
if (width === 0) return "";
const baseY = height / 2;
const points = data.map((d, i) => {
const x = (i / 89) * width;
const val = type === "home" ? d.home : d.away;
const y =
type === "home"
? baseY - (val / 50) * baseY
: baseY + (val / 50) * baseY;
return { x, y };
});
// 创建平滑曲线 (使用简单指令)
let d = `M 0 ${baseY}`;
points.forEach((p) => {
d += ` L ${p.x} ${p.y}`;
});
d += ` L ${width} ${baseY} Z`;
return d;
};
const chartData = generateData;
const iconTintColor = isDark ? "#888" : "#CCC";
const switchBg = isDark ? "#2C2C2E" : "#F0F0F0";
return (
<ThemedView style={[styles.container, isDark && styles.darkContainer]}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<ThemedText style={styles.title}>
{t("detail.stats_card.title")}
</ThemedText>
<TouchableOpacity onPress={() => setShowInfo(true)}>
<IconSymbol
name="information-circle-outline"
size={16}
color={iconTintColor}
/>
</TouchableOpacity>
</View>
<View style={styles.switches}>
<View style={[styles.switchWrapper, { backgroundColor: switchBg }]}>
<IconSymbol name="flag" size={14} color="#4CAF50" />
<Switch
value={flagSwitch}
onValueChange={setFlagSwitch}
trackColor={{ false: "#DDD", true: "#EEE" }}
thumbColor={flagSwitch ? "#A5D6A7" : "#FFF"}
style={styles.smallSwitch}
/>
</View>
<View style={[styles.switchWrapper, { backgroundColor: switchBg }]}>
<View style={[styles.miniCard, { backgroundColor: "#FFD700" }]} />
<Switch
value={cardSwitch}
onValueChange={setCardSwitch}
trackColor={{ false: "#DDD", true: "#3b5998" }}
thumbColor="#FFF"
style={styles.smallSwitch}
/>
</View>
</View>
</View>
{/* Momentum Chart Area */}
<View style={styles.chartWrapper}>
<View style={styles.teamLogos}>
<Image
source={{ uri: match.home_team_logo }}
style={styles.chartLogo}
/>
<Image
source={{ uri: match.away_team_logo }}
style={[styles.chartLogo, { marginTop: 12 }]}
/>
</View>
<View style={styles.chartArea}>
{/* 背景色带 */}
<LinearGradient
colors={["rgba(59, 89, 152, 0.05)", "rgba(255, 171, 0, 0.05)"]}
style={styles.bgGradient}
/>
{/* 网格线和时间刻度 */}
<View style={styles.gridContainer}>
{["0'", "15'", "30'", "HT", "60'", "75'", "90'"].map((label, i) => (
<View key={i} style={styles.gridLine}>
<View
style={[styles.line, isDark && { backgroundColor: "#333" }]}
/>
<ThemedText
style={[styles.gridLabel, isDark && { color: "#888" }]}
>
{label}
</ThemedText>
</View>
))}
</View>
{/* 压力曲线模拟 (Wave Chart) */}
<View style={styles.waveContainer} onLayout={onChartLayout}>
{chartWidth > 0 && (
<Svg
width={chartWidth}
height={110}
style={{ position: "absolute" }}
>
<Defs>
<SvgLinearGradient
id="homeGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<Stop offset="0" stopColor="#3b5998" stopOpacity="0.6" />
<Stop offset="1" stopColor="#3b5998" stopOpacity="0" />
</SvgLinearGradient>
<SvgLinearGradient
id="awayGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<Stop offset="0" stopColor="#FFAB00" stopOpacity="0" />
<Stop offset="1" stopColor="#FFAB00" stopOpacity="0.6" />
</SvgLinearGradient>
</Defs>
{/* Home Wave */}
<Path
d={getWavePath(chartData, "home", chartWidth, 110)}
fill="url(#homeGradient)"
stroke="#3b5998"
strokeWidth="1.5"
/>
{/* Away Wave */}
<Path
d={getWavePath(chartData, "away", chartWidth, 110)}
fill="url(#awayGradient)"
stroke="#FFAB00"
strokeWidth="1.5"
/>
</Svg>
)}
</View>
{/* 动态标记:红黄牌 */}
{chartWidth > 0 &&
cardSwitch &&
match.cards?.map((card, idx) => {
const time = parseInt(card.time) || 0;
const left = (time / 90) * chartWidth;
const isHome = card.home_fault !== "";
const isYellow = (card.card || "")
.toLowerCase()
.includes("yellow");
return (
<View
key={`card-${idx}`}
style={[
styles.eventMarker,
{ left, top: isHome ? "5%" : "72%" },
]}
>
<View
style={[
styles.square,
{
backgroundColor: isYellow ? "#FFD700" : "#FF3B30",
width: 8,
height: 12,
borderRadius: 1,
},
]}
/>
</View>
);
})}
{/* 动态标记:进球 */}
{chartWidth > 0 &&
flagSwitch &&
match.goalscorers?.map((goal, idx) => {
const time = parseInt(goal.time) || 0;
const left = (time / 90) * chartWidth;
const isHome = !!goal.home_scorer;
return (
<View
key={`goal-${idx}`}
style={[
styles.eventMarker,
{ left, top: isHome ? "25%" : "55%" },
]}
>
<View style={styles.goalBox}>
<IconSymbol name="football" size={10} color="#000" />
</View>
</View>
);
})}
</View>
</View>
{/* Time Progress Slider */}
<View style={styles.sliderContainer}>
<ThemedText style={styles.currentTime}>{displayTime}</ThemedText>
<View style={styles.trackContainer}>
<View style={[styles.track, isDark && { backgroundColor: "#333" }]} />
<View
style={[styles.filledTrack, { width: `${progressPercent}%` }]}
/>
<View style={[styles.thumb, { left: "0%" }]} />
<View
style={[
styles.thumb,
{
left: `${progressPercent}%`,
backgroundColor: "#FFAB00",
borderColor: "#FFF",
},
]}
/>
<View
style={[
styles.thumb,
{ left: "100%", backgroundColor: isDark ? "#555" : "#333" },
]}
/>
</View>
<View style={styles.trackLabels}>
<ThemedText style={styles.trackLabel}>0:00</ThemedText>
<ThemedText style={styles.trackLabel}>HT</ThemedText>
<ThemedText style={styles.trackLabel}>FT</ThemedText>
</View>
</View>
{/* Possession & Stats Bar */}
<View style={styles.possessionSection}>
<ThemedText style={styles.possessionTitle}>
{t("detail.stats_card.possession")}
</ThemedText>
<View style={styles.possessionBarRow}>
<View
style={[
styles.posBar,
{
flex: homePossession,
backgroundColor: "#3b5998",
borderTopLeftRadius: 10,
borderBottomLeftRadius: 10,
},
]}
>
<Image
source={{ uri: match.home_team_logo }}
style={styles.posLogo}
/>
<ThemedText style={styles.posValue}>{homePossession}%</ThemedText>
</View>
<View style={{ width: 4 }} />
<View
style={[
styles.posBar,
{
flex: awayPossession,
backgroundColor: "#FFAB00",
borderTopRightRadius: 10,
borderBottomRightRadius: 10,
flexDirection: "row-reverse",
},
]}
>
<Image
source={{ uri: match.away_team_logo }}
style={styles.posLogo}
/>
<ThemedText style={styles.posValue}>{awayPossession}%</ThemedText>
</View>
</View>
<View style={styles.miniStatsRow}>
<View style={[styles.miniStatBadge, isDark && styles.darkStatBadge]}>
<ThemedText style={styles.miniStatText}>{corners.home}</ThemedText>
<IconSymbol name="flag" size={14} color="#FFAB00" />
<ThemedText style={styles.miniStatText}>{corners.away}</ThemedText>
</View>
<View style={[styles.miniStatBadge, isDark && styles.darkStatBadge]}>
<ThemedText style={styles.miniStatText}>
{yellowCards.home}
</ThemedText>
<View style={styles.yellowCardIcon} />
<ThemedText style={styles.miniStatText}>
{yellowCards.away}
</ThemedText>
</View>
</View>
</View>
{/* Momentum Info Modal */}
<Modal
visible={showInfo}
transparent
animationType="fade"
onRequestClose={() => setShowInfo(false)}
>
<Pressable
style={styles.modalOverlay}
onPress={() => setShowInfo(false)}
>
<ThemedView
style={[styles.modalContent, isDark && styles.darkModalContent]}
>
<ThemedText style={styles.modalTitle}>
{t("detail.stats_card.info_title")}
</ThemedText>
<ThemedText style={styles.modalDesc}>
{t("detail.stats_card.info_desc")}
</ThemedText>
<View style={styles.bulletPoint}>
<ThemedText style={styles.bulletText}>
{t("detail.stats_card.point1")}
</ThemedText>
</View>
<View style={styles.bulletPoint}>
<ThemedText style={styles.bulletText}>
{t("detail.stats_card.point2")}
</ThemedText>
</View>
<View style={styles.bulletPoint}>
<ThemedText style={styles.bulletText}>
{t("detail.stats_card.point3")}
</ThemedText>
</View>
<View style={styles.teamInfoRow}>
<Image
source={{ uri: match.home_team_logo }}
style={styles.miniLogo}
/>
<View style={styles.wavePlaceholder}>
{[...Array(6)].map((_, i) => (
<View
key={i}
style={[
styles.waveDot,
{
backgroundColor: "#3b5998",
marginTop: i % 2 === 0 ? 0 : 6,
},
]}
/>
))}
</View>
<ThemedText style={styles.teamDesc} numberOfLines={1}>
{match.event_home_team}
</ThemedText>
</View>
<View style={styles.teamInfoRow}>
<Image
source={{ uri: match.away_team_logo }}
style={styles.miniLogo}
/>
<View style={styles.wavePlaceholder}>
{[...Array(6)].map((_, i) => (
<View
key={i}
style={[
styles.waveDot,
{
backgroundColor: "#FFAB00",
marginTop: i % 2 === 0 ? 6 : 0,
},
]}
/>
))}
</View>
<ThemedText style={styles.teamDesc} numberOfLines={1}>
{match.event_away_team}
</ThemedText>
</View>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowInfo(false)}
>
<ThemedText style={styles.closeButtonText}>
{t("detail.stats_card.close")}
</ThemedText>
</TouchableOpacity>
</ThemedView>
</Pressable>
</Modal>
</ThemedView>
);
}
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",
},
});