添加网球详情
This commit is contained in:
@@ -5,6 +5,9 @@ import { LiveScoreHeader } from "@/components/live-detail/live-score-header";
|
|||||||
import { OddsCard } from "@/components/live-detail/odds-card";
|
import { OddsCard } from "@/components/live-detail/odds-card";
|
||||||
import { OtherInfoCard } from "@/components/live-detail/other-info-card";
|
import { OtherInfoCard } from "@/components/live-detail/other-info-card";
|
||||||
import { StatsCard } from "@/components/live-detail/stats-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 { ThemedText } from "@/components/themed-text";
|
||||||
import { ThemedView } from "@/components/themed-view";
|
import { ThemedView } from "@/components/themed-view";
|
||||||
import { Colors } from "@/constants/theme";
|
import { Colors } from "@/constants/theme";
|
||||||
@@ -99,14 +102,37 @@ export default function LiveDetailScreen() {
|
|||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
const numericSportId = parseInt(sport_id || "1");
|
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) {
|
switch (activeTab) {
|
||||||
case "stats":
|
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":
|
case "odds":
|
||||||
return (
|
return (
|
||||||
<OddsCard match={match} isDark={isDark} sportId={numericSportId} />
|
<OddsCard match={match} isDark={isDark} sportId={numericSportId} />
|
||||||
);
|
);
|
||||||
case "detail":
|
case "detail":
|
||||||
|
if (isTennis) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TennisScoreboard match={match} isDark={isDark} />
|
||||||
|
<TennisStatsCard match={match} isDark={isDark} />
|
||||||
|
<TennisPowerGraph match={match} isDark={isDark} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<OddsCard match={match} isDark={isDark} sportId={numericSportId} />
|
<OddsCard match={match} isDark={isDark} sportId={numericSportId} />
|
||||||
|
|||||||
@@ -151,9 +151,24 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const lastServerMatchRef = React.useRef(
|
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(() => {
|
React.useEffect(() => {
|
||||||
const currentKey = `${match.event_status}-${match.event_time}`;
|
const currentKey = `${match.event_status}-${match.event_time}`;
|
||||||
@@ -251,13 +266,13 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.logoContainer}>
|
<View style={styles.logoContainer}>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: match.home_team_logo }}
|
source={{ uri: homeLogo }}
|
||||||
style={styles.teamLogo}
|
style={[styles.teamLogo, isTennis && styles.tennisAvatar]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ThemedText style={styles.teamName} numberOfLines={2}>
|
<ThemedText style={styles.teamName} numberOfLines={2}>
|
||||||
{match.event_home_team}
|
{homeName}
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -274,10 +289,12 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
|
|||||||
{match.event_status}
|
{match.event_status}
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
)}
|
)}
|
||||||
<ThemedText style={styles.timeText}>{displayTime}</ThemedText>
|
{!isTennis && (
|
||||||
|
<ThemedText style={styles.timeText}>{displayTime}</ThemedText>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{match.goalscorers && match.goalscorers.length > 0 && (
|
{!isTennis && match.goalscorers && match.goalscorers.length > 0 && (
|
||||||
<View style={styles.lastGoalContainer}>
|
<View style={styles.lastGoalContainer}>
|
||||||
<IconSymbol name="football-outline" size={12} color="#FFF" />
|
<IconSymbol name="football-outline" size={12} color="#FFF" />
|
||||||
<ThemedText style={styles.lastGoalText}>
|
<ThemedText style={styles.lastGoalText}>
|
||||||
@@ -286,14 +303,24 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
|
|||||||
</ThemedText>
|
</ThemedText>
|
||||||
</View>
|
</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>
|
||||||
|
|
||||||
<View style={styles.teamInfo}>
|
<View style={styles.teamInfo}>
|
||||||
<View style={styles.teamLogoRow}>
|
<View style={styles.teamLogoRow}>
|
||||||
<View style={styles.logoContainer}>
|
<View style={styles.logoContainer}>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: match.away_team_logo }}
|
source={{ uri: awayLogo }}
|
||||||
style={styles.teamLogo}
|
style={[styles.teamLogo, isTennis && styles.tennisAvatar]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -309,7 +336,7 @@ export function LiveScoreHeader({ match, topInset }: LiveScoreHeaderProps) {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<ThemedText style={styles.teamName} numberOfLines={2}>
|
<ThemedText style={styles.teamName} numberOfLines={2}>
|
||||||
{match.event_away_team}
|
{awayName}
|
||||||
</ThemedText>
|
</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -442,4 +469,21 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
marginLeft: 4,
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,12 +87,13 @@ export function StatsCard({ match, isDark }: StatsCardProps) {
|
|||||||
const progressPercent = Math.min(100, (totalSeconds / (90 * 60)) * 100);
|
const progressPercent = Math.min(100, (totalSeconds / (90 * 60)) * 100);
|
||||||
|
|
||||||
// 从 statistics 中提取数据
|
// 从 statistics 中提取数据
|
||||||
const stats = match.statistics || [];
|
const stats = (match.statistics || []) as any[];
|
||||||
const getStatValue = (type: string) => {
|
const getStatValue = (type: string) => {
|
||||||
const stat = stats.find((s) => s.type === type);
|
const stat = stats.find((s) => s.type === type);
|
||||||
return stat
|
if (stat && stat.home) {
|
||||||
? { home: stat.home, away: stat.away }
|
return { home: stat.home, away: stat.away };
|
||||||
: { home: "0", away: "0" };
|
}
|
||||||
|
return { home: "0", away: "0" };
|
||||||
};
|
};
|
||||||
|
|
||||||
const possession = getStatValue("Ball Possession");
|
const possession = getStatValue("Ball Possession");
|
||||||
|
|||||||
332
components/live-detail/tennis-power-graph.tsx
Normal file
332
components/live-detail/tennis-power-graph.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
244
components/live-detail/tennis-scoreboard.tsx
Normal file
244
components/live-detail/tennis-scoreboard.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
408
components/live-detail/tennis-stats-card.tsx
Normal file
408
components/live-detail/tennis-stats-card.tsx
Normal 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%",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -262,14 +262,15 @@ export function MatchCard({
|
|||||||
return { home: "", away: "" };
|
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) {
|
if (corners) {
|
||||||
return { home: corners.home, away: corners.away };
|
return { home: corners.home, away: corners.away };
|
||||||
}
|
}
|
||||||
|
|
||||||
const dangerousAttacks = liveDetail.statistics.find(
|
const dangerousAttacks = stats.find((s) => s.type === "Dangerous Attacks");
|
||||||
(s) => s.type === "Dangerous Attacks",
|
|
||||||
);
|
|
||||||
if (dangerousAttacks) {
|
if (dangerousAttacks) {
|
||||||
return { home: dangerousAttacks.home, away: dangerousAttacks.away };
|
return { home: dangerousAttacks.home, away: dangerousAttacks.away };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,18 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"fetch_failed": "Failed to fetch details",
|
"fetch_failed": "Failed to fetch details",
|
||||||
"not_found": "Match data not found",
|
"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": {
|
"tabs": {
|
||||||
"info": "Details",
|
"info": "Details",
|
||||||
"stats": "Statistics",
|
"stats": "Statistics",
|
||||||
|
|||||||
@@ -96,6 +96,7 @@
|
|||||||
"h2h": "आमने-सामने",
|
"h2h": "आमने-सामने",
|
||||||
"chat": "चैट"
|
"chat": "चैट"
|
||||||
},
|
},
|
||||||
|
"scoreboard": "स्कोरबोर्ड",
|
||||||
"info_card": {
|
"info_card": {
|
||||||
"title": "मैच जानकारी",
|
"title": "मैच जानकारी",
|
||||||
"country": "देश/क्षेत्र",
|
"country": "देश/क्षेत्र",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"retry": "Coba lagi",
|
"retry": "Coba lagi",
|
||||||
"fetch_failed": "Gagal mengambil data",
|
"fetch_failed": "Gagal mengambil data",
|
||||||
"not_found": "Data pertandingan tidak ditemukan",
|
"not_found": "Data pertandingan tidak ditemukan",
|
||||||
|
"scoreboard": "Papan Skor",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"info": "Detail",
|
"info": "Detail",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"retry": "Cuba semula",
|
"retry": "Cuba semula",
|
||||||
"fetch_failed": "Gagal mendapatkan maklumat",
|
"fetch_failed": "Gagal mendapatkan maklumat",
|
||||||
"not_found": "Data perlawanan tidak ditemui",
|
"not_found": "Data perlawanan tidak ditemui",
|
||||||
|
"scoreboard": "Papan Skor",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"info": "Maklumat",
|
"info": "Maklumat",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"retry": "ลองอีกครั้ง",
|
"retry": "ลองอีกครั้ง",
|
||||||
"fetch_failed": "ไม่สามารถดึงข้อมูลได้",
|
"fetch_failed": "ไม่สามารถดึงข้อมูลได้",
|
||||||
"not_found": "ไม่พบข้อมูลการแข่งขัน",
|
"not_found": "ไม่พบข้อมูลการแข่งขัน",
|
||||||
|
"scoreboard": "สกอร์บอร์ด",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"info": "รายละเอียด",
|
"info": "รายละเอียด",
|
||||||
"stats": "สถิติ",
|
"stats": "สถิติ",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"retry": "Thử lại",
|
"retry": "Thử lại",
|
||||||
"fetch_failed": "Không thể tải dữ liệu",
|
"fetch_failed": "Không thể tải dữ liệu",
|
||||||
"not_found": "Không tìm thấy dữ liệu trận đấu",
|
"not_found": "Không tìm thấy dữ liệu trận đấu",
|
||||||
|
"scoreboard": "Bảng điểm",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"info": "Thông tin",
|
"info": "Thông tin",
|
||||||
"stats": "Thống kê",
|
"stats": "Thống kê",
|
||||||
|
|||||||
@@ -91,6 +91,18 @@
|
|||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"fetch_failed": "获取详情失败",
|
"fetch_failed": "获取详情失败",
|
||||||
"not_found": "未找到比赛数据",
|
"not_found": "未找到比赛数据",
|
||||||
|
"scoreboard": "记分牌",
|
||||||
|
"power_graph": "功率图",
|
||||||
|
"statistics": "统计数据",
|
||||||
|
"stats": {
|
||||||
|
"first_serve": "一发成功率",
|
||||||
|
"first_serve_points": "一发得分率",
|
||||||
|
"break_points": "破发成功率",
|
||||||
|
"aces": "Ace球",
|
||||||
|
"double_faults": "双误",
|
||||||
|
"service_points": "发球得分",
|
||||||
|
"total_points": "总得分"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"info": "详情",
|
"info": "详情",
|
||||||
"stats": "统计数据",
|
"stats": "统计数据",
|
||||||
|
|||||||
12
lib/api.ts
12
lib/api.ts
@@ -167,7 +167,7 @@ export const fetchTodayMatches = async (options: {
|
|||||||
const year = options.date.getFullYear();
|
const year = options.date.getFullYear();
|
||||||
const month = String(options.date.getMonth() + 1).padStart(2, "0");
|
const month = String(options.date.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(options.date.getDate()).padStart(2, "0");
|
const day = String(options.date.getDate()).padStart(2, "0");
|
||||||
params.date = `${year}-${month}-${day}`;
|
params.date = `${year}-${month}-${day}`;
|
||||||
} else {
|
} else {
|
||||||
params.date = options.date;
|
params.date = options.date;
|
||||||
}
|
}
|
||||||
@@ -214,11 +214,11 @@ export const fetchLiveScore = async (
|
|||||||
leagueId?: number,
|
leagueId?: number,
|
||||||
timezone?: string,
|
timezone?: string,
|
||||||
): Promise<LiveScoreMatch[]> => {
|
): Promise<LiveScoreMatch[]> => {
|
||||||
// console.log("Fetching live scores with params:", {
|
console.log("Fetching live scores with params:", {
|
||||||
// sportId,
|
sportId,
|
||||||
// leagueId,
|
leagueId,
|
||||||
// timezone,
|
timezone,
|
||||||
// });
|
});
|
||||||
try {
|
try {
|
||||||
const params: { sport_id: number; league_id?: number; timezone?: string } =
|
const params: { sport_id: number; league_id?: number; timezone?: string } =
|
||||||
{
|
{
|
||||||
|
|||||||
41
types/api.ts
41
types/api.ts
@@ -58,6 +58,7 @@ export interface LiveScoreMatch {
|
|||||||
away_team_logo: string;
|
away_team_logo: string;
|
||||||
event_final_result: string;
|
event_final_result: string;
|
||||||
event_halftime_result: string;
|
event_halftime_result: string;
|
||||||
|
event_game_result?: string;
|
||||||
event_status: string;
|
event_status: string;
|
||||||
event_live: string;
|
event_live: string;
|
||||||
league_key: number;
|
league_key: number;
|
||||||
@@ -103,20 +104,44 @@ export interface LiveScoreMatch {
|
|||||||
substitutes?: {
|
substitutes?: {
|
||||||
time: string;
|
time: string;
|
||||||
home_scorer:
|
home_scorer:
|
||||||
| { in: string; out: string; in_id: number; out_id: number }
|
| { in: string; out: string; in_id: number; out_id: number }
|
||||||
| any[];
|
| any[];
|
||||||
away_scorer:
|
away_scorer:
|
||||||
| { in: string; out: string; in_id: number; out_id: number }
|
| { in: string; out: string; in_id: number; out_id: number }
|
||||||
| any[];
|
| any[];
|
||||||
info: string;
|
info: string;
|
||||||
info_time: string;
|
info_time: string;
|
||||||
score: string;
|
score: string;
|
||||||
}[];
|
}[];
|
||||||
lineups?: unknown;
|
lineups?: unknown;
|
||||||
statistics?: {
|
statistics?:
|
||||||
type: string;
|
| {
|
||||||
home: string;
|
type: string;
|
||||||
away: 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;
|
||||||
|
}[];
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user