463 lines
13 KiB
TypeScript
463 lines
13 KiB
TypeScript
import { MatchCardLeague } from "@/components/match-card-league";
|
|
import { ThemedText } from "@/components/themed-text";
|
|
import { Colors } from "@/constants/theme";
|
|
import { useTheme } from "@/context/ThemeContext";
|
|
import { fetchLeagues, fetchTodayMatches } from "@/lib/api";
|
|
import { getInitials, getLogoGradient } from "@/lib/avatar-utils";
|
|
import { League, Match } from "@/types/api";
|
|
import { Image } from "expo-image";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import React, { useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
LayoutAnimation,
|
|
Platform,
|
|
ScrollView,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
UIManager,
|
|
View,
|
|
} from "react-native";
|
|
import Animated, {
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
withTiming,
|
|
} from "react-native-reanimated";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
|
|
// 开启 Android 上的 LayoutAnimation
|
|
if (
|
|
Platform.OS === "android" &&
|
|
UIManager.setLayoutAnimationEnabledExperimental
|
|
) {
|
|
UIManager.setLayoutAnimationEnabledExperimental(true);
|
|
}
|
|
|
|
interface MatchesByLeagueProps {
|
|
sportId: number;
|
|
date: Date;
|
|
timezone: string;
|
|
onFavoriteToggle?: (matchId: string, isFav: boolean) => void;
|
|
enableCollapsible?: boolean;
|
|
}
|
|
|
|
export function MatchesByLeague({
|
|
sportId,
|
|
date,
|
|
timezone,
|
|
onFavoriteToggle,
|
|
enableCollapsible = true,
|
|
}: MatchesByLeagueProps) {
|
|
const insets = useSafeAreaInsets();
|
|
const { theme } = useTheme();
|
|
const isDark = theme === "dark";
|
|
|
|
function ChevronIcon({ isCollapsed, isDark }: { isCollapsed: boolean; isDark: boolean }) {
|
|
const rotation = useSharedValue(isCollapsed ? 0 : 180);
|
|
|
|
React.useEffect(() => {
|
|
rotation.value = withTiming(isCollapsed ? 0 : 180, { duration: 200 });
|
|
}, [isCollapsed, rotation]);
|
|
|
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
transform: [{ rotate: `${rotation.value}deg` }],
|
|
}));
|
|
|
|
return (
|
|
<View style={styles.leagueHeaderRight}>
|
|
<Animated.View style={[styles.chevronContainer, animatedStyle]}>
|
|
<Image
|
|
source={
|
|
isDark
|
|
? require("@/assets/down.svg")
|
|
: require("@/assets/down-light.svg")
|
|
}
|
|
style={styles.chevronIcon}
|
|
contentFit="contain"
|
|
/>
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const [leagues, setLeagues] = useState<League[]>([]);
|
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
const [matchesByLeagueKey, setMatchesByLeagueKey] = useState<
|
|
Record<string, Match[]>
|
|
>({});
|
|
const [loadingLeagueKey, setLoadingLeagueKey] = useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
const [loadingLeagues, setLoadingLeagues] = useState(true);
|
|
|
|
const dateStr = React.useMemo(() => {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}, [date]);
|
|
|
|
React.useEffect(() => {
|
|
let mounted = true;
|
|
setLoadingLeagues(true);
|
|
fetchLeagues({
|
|
sportId,
|
|
date: dateStr,
|
|
page: 1,
|
|
pageSize: 200,
|
|
sortBy: "matchCount",
|
|
sortOrder: "desc",
|
|
})
|
|
.then((res) => {
|
|
if (!mounted) return;
|
|
setLeagues(res.list);
|
|
setCollapsed((prev) => {
|
|
const next: Record<string, boolean> = {};
|
|
res.list.forEach((l) => {
|
|
next[l.key] = prev[l.key] !== undefined ? prev[l.key] : true;
|
|
});
|
|
return next;
|
|
});
|
|
setMatchesByLeagueKey({});
|
|
setLoadingLeagueKey({});
|
|
setLoadingLeagues(false);
|
|
})
|
|
.catch(() => {
|
|
if (!mounted) return;
|
|
setLoadingLeagues(false);
|
|
});
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [sportId, dateStr]);
|
|
|
|
const toggleCollapse = async (leagueKey: string) => {
|
|
if (!enableCollapsible) return;
|
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
|
setCollapsed((prev) => ({ ...prev, [leagueKey]: !prev[leagueKey] }));
|
|
|
|
const nextCollapsed = !collapsed[leagueKey];
|
|
if (nextCollapsed) return;
|
|
if (matchesByLeagueKey[leagueKey]) return;
|
|
|
|
setLoadingLeagueKey((prev) => ({ ...prev, [leagueKey]: true }));
|
|
try {
|
|
const res = await fetchTodayMatches({
|
|
sportId,
|
|
date: dateStr,
|
|
timezone,
|
|
leagueKey,
|
|
page: 1,
|
|
pageSize: 50,
|
|
});
|
|
console.log("choose", res);
|
|
setMatchesByLeagueKey((prev) => ({ ...prev, [leagueKey]: res.list }));
|
|
} finally {
|
|
setLoadingLeagueKey((prev) => ({ ...prev, [leagueKey]: false }));
|
|
}
|
|
};
|
|
|
|
if (loadingLeagues) {
|
|
return (
|
|
<View style={styles.emptyContainer}>
|
|
<ActivityIndicator
|
|
size="large"
|
|
color={isDark ? Colors.dark.tint : Colors.light.tint}
|
|
/>
|
|
<ThemedText style={{ marginTop: 10 }}>加载中...</ThemedText>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (leagues.length === 0) {
|
|
return (
|
|
<View style={styles.emptyContainer}>
|
|
<ThemedText>暂无联赛</ThemedText>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const cardBg = isDark ? "#1C1C1E" : "#FFFFFF";
|
|
const dividerColor = isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
|
|
const skeletonBg = isDark ? "#2C2C2E" : "#E5E5E5";
|
|
const headerBg = isDark ? "#1C1C1E" : "#FBFBFB";
|
|
|
|
return (
|
|
<ScrollView
|
|
style={[
|
|
styles.container,
|
|
{ backgroundColor: isDark ? Colors.dark.background : "#FFFFFF" },
|
|
]}
|
|
contentContainerStyle={{
|
|
paddingBottom: 80 + insets.bottom,
|
|
paddingTop: 8,
|
|
}}
|
|
>
|
|
{leagues.map((league) => {
|
|
const isCollapsed = collapsed[league.key] !== false;
|
|
const leagueMatches = matchesByLeagueKey[league.key] || [];
|
|
const isLoading = !!loadingLeagueKey[league.key];
|
|
|
|
return (
|
|
<View key={league.key} style={styles.leagueSection}>
|
|
<TouchableOpacity
|
|
activeOpacity={enableCollapsible && league.matchCount > 0 ? 0.7 : 1}
|
|
onPress={() => {
|
|
if (enableCollapsible && league.matchCount > 0) {
|
|
toggleCollapse(league.key);
|
|
}
|
|
}}
|
|
style={[styles.leagueHeaderWrapper, { backgroundColor: headerBg }]}
|
|
>
|
|
<View style={styles.leagueHeaderLeft}>
|
|
{(() => {
|
|
const hasLogo = league.logo && league.logo.trim() !== "" && !league.logo.includes("placehold");
|
|
const gradient = getLogoGradient(league.name || "");
|
|
const initials = getInitials(league.name || "");
|
|
|
|
return hasLogo ? (
|
|
<Image
|
|
source={{ uri: league.logo }}
|
|
style={[styles.leagueLogo, { backgroundColor: isDark ? "#3A3A3C" : "#E5E5E5" }]}
|
|
/>
|
|
) : (
|
|
<LinearGradient
|
|
colors={[gradient.color1, gradient.color2]}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={styles.leagueLogoGradient}
|
|
>
|
|
<ThemedText style={styles.leagueLogoText}>{initials}</ThemedText>
|
|
</LinearGradient>
|
|
);
|
|
})()}
|
|
|
|
<View style={styles.leagueInfoText}>
|
|
<ThemedText
|
|
style={[
|
|
styles.leagueTitle,
|
|
{ color: isDark ? Colors.dark.text : Colors.light.text },
|
|
]}
|
|
>
|
|
{league.name}
|
|
</ThemedText>
|
|
|
|
<View style={styles.countryRow}>
|
|
<Image
|
|
source={{ uri: league.countryLogo || "https://placehold.co/20x20/png" }}
|
|
style={styles.countryFlag}
|
|
/>
|
|
<ThemedText style={[styles.countryName, { color: isDark ? Colors.dark.text : Colors.light.text }]}>
|
|
{league.countryName || "International"}
|
|
</ThemedText>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{enableCollapsible && league.matchCount > 0 && (
|
|
<ChevronIcon isCollapsed={isCollapsed} isDark={isDark} />
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
{!isCollapsed && (
|
|
<View
|
|
style={[
|
|
styles.matchListContainer,
|
|
{ backgroundColor: cardBg, marginTop: 8 },
|
|
]}
|
|
>
|
|
{isLoading ? (
|
|
<View style={styles.matchCardWrapper}>
|
|
<View style={styles.skeletonRow}>
|
|
<View style={styles.leftColumn}>
|
|
<View style={[styles.skeletonLine, { width: 30, backgroundColor: skeletonBg }]} />
|
|
</View>
|
|
<View style={styles.teamsColumn}>
|
|
<View style={styles.skeletonTeamRow}>
|
|
<View style={[styles.skeletonAvatar, { backgroundColor: skeletonBg }]} />
|
|
<View style={[styles.skeletonLine, { flex: 1, backgroundColor: skeletonBg }]} />
|
|
</View>
|
|
<View style={[styles.skeletonTeamRow, { marginTop: 10 }]}>
|
|
<View style={[styles.skeletonAvatar, { backgroundColor: skeletonBg }]} />
|
|
<View style={[styles.skeletonLine, { flex: 1, backgroundColor: skeletonBg }]} />
|
|
</View>
|
|
</View>
|
|
<View style={styles.rightWrapper}>
|
|
<View style={[styles.skeletonScoreBox, { backgroundColor: skeletonBg }]} />
|
|
<View style={styles.favoriteButton}>
|
|
<View style={[styles.skeletonCircle, { backgroundColor: skeletonBg }]} />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
leagueMatches.map((match, index) => (
|
|
<View
|
|
key={match.id}
|
|
style={[
|
|
styles.matchCardWrapper,
|
|
index < leagueMatches.length - 1 && [styles.matchCardDivider, { borderBottomColor: dividerColor }],
|
|
]}
|
|
>
|
|
<MatchCardLeague
|
|
match={match}
|
|
onFavoriteToggle={onFavoriteToggle}
|
|
/>
|
|
</View>
|
|
))
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
leagueSection: {
|
|
marginBottom: 8,
|
|
},
|
|
leagueHeaderWrapper: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 14,
|
|
borderRadius: 12,
|
|
marginHorizontal: 16,
|
|
},
|
|
leagueHeaderLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
flex: 1,
|
|
},
|
|
leagueLogo: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 8,
|
|
marginRight: 12,
|
|
marginLeft: 14,
|
|
},
|
|
leagueLogoGradient: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 8,
|
|
marginRight: 12,
|
|
marginLeft: 14,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
leagueLogoText: {
|
|
fontSize: 9,
|
|
fontWeight: "700",
|
|
color: "rgba(255, 255, 255, 0.92)",
|
|
},
|
|
leagueInfoText: {
|
|
justifyContent: "center",
|
|
},
|
|
leagueTitle: {
|
|
fontSize: 14,
|
|
fontWeight: "700",
|
|
marginBottom: 4,
|
|
lineHeight: 16,
|
|
},
|
|
countryRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
countryFlag: {
|
|
width: 14,
|
|
height: 14,
|
|
marginRight: 6,
|
|
borderRadius: 2,
|
|
},
|
|
countryName: {
|
|
fontSize: 12,
|
|
fontWeight: "500",
|
|
lineHeight: 12,
|
|
},
|
|
leagueHeaderRight: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
chevronContainer: {
|
|
width: 16,
|
|
height: 16,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
chevronIcon: {
|
|
width: 16,
|
|
height: 16,
|
|
},
|
|
matchListContainer: {
|
|
borderRadius: 16,
|
|
marginHorizontal: 16,
|
|
overflow: "hidden",
|
|
},
|
|
leftColumn: {
|
|
width: 48,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
marginRight: 8,
|
|
},
|
|
teamsColumn: {
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
paddingRight: 12,
|
|
},
|
|
rightWrapper: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
matchCardWrapper: {},
|
|
matchCardDivider: {
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
},
|
|
favoriteButton: {
|
|
paddingLeft: 16,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
skeletonRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 16,
|
|
},
|
|
skeletonLine: {
|
|
height: 10,
|
|
borderRadius: 5,
|
|
},
|
|
skeletonAvatar: {
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 14,
|
|
marginRight: 12,
|
|
},
|
|
skeletonTeamRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
skeletonScoreBox: {
|
|
width: 40,
|
|
height: 64,
|
|
borderRadius: 8,
|
|
},
|
|
skeletonCircle: {
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 11,
|
|
},
|
|
emptyContainer: {
|
|
flex: 1,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
paddingTop: 100,
|
|
},
|
|
}); |