实现收藏页面
This commit is contained in:
@@ -1,11 +1,283 @@
|
|||||||
|
import { MatchCard } from "@/components/match-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 { StyleSheet } from "react-native";
|
import { IconSymbol, IconSymbolName } from "@/components/ui/icon-symbol";
|
||||||
|
import { Colors } from "@/constants/theme";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { fetchFavorites, removeFavorite } from "@/lib/api";
|
||||||
|
import { FavoriteItem, Match } from "@/types/api";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
type FilterType = "match" | "team" | "player";
|
||||||
|
|
||||||
|
interface FilterTab {
|
||||||
|
key: FilterType;
|
||||||
|
label: string;
|
||||||
|
icon: IconSymbolName;
|
||||||
|
}
|
||||||
|
|
||||||
export default function FavoriteScreen() {
|
export default function FavoriteScreen() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
const iconColor = isDark ? Colors.dark.icon : Colors.light.icon;
|
||||||
|
const textColor = isDark ? Colors.dark.text : Colors.light.text;
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<FilterType>("match");
|
||||||
|
const [favorites, setFavorites] = useState<FavoriteItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
|
||||||
|
const tabs: FilterTab[] = [
|
||||||
|
{
|
||||||
|
key: "match",
|
||||||
|
label: t("favorites.filter_match"),
|
||||||
|
icon: "football-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "team",
|
||||||
|
label: t("favorites.filter_team"),
|
||||||
|
icon: "shield-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "player",
|
||||||
|
label: t("favorites.filter_player"),
|
||||||
|
icon: "person-outline",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const loadFavorites = async (isRefresh = false) => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const currentPage = isRefresh ? 1 : page;
|
||||||
|
const res = await fetchFavorites(activeTab, currentPage);
|
||||||
|
if (isRefresh) {
|
||||||
|
setFavorites(res.list);
|
||||||
|
setHasMore(res.list.length >= 20); // Assuming pageSize 20
|
||||||
|
} else {
|
||||||
|
setFavorites((prev) => [...prev, ...res.list]);
|
||||||
|
setHasMore(res.list.length >= 20);
|
||||||
|
}
|
||||||
|
if (res.list.length > 0) {
|
||||||
|
setPage(currentPage + 1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setFavorites([]);
|
||||||
|
setHasMore(true);
|
||||||
|
loadFavorites(true);
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(true);
|
||||||
|
loadFavorites(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (!loading && hasMore) {
|
||||||
|
loadFavorites(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnfavorite = async (type: string, typeId: string) => {
|
||||||
|
try {
|
||||||
|
await removeFavorite({ type, typeId });
|
||||||
|
setFavorites((prev) => prev.filter((item) => item.typeId !== typeId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Remove favorite failed", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMatchItem = (item: FavoriteItem) => {
|
||||||
|
const m = item.match;
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
// Use current date as placeholder if not provided, or parse eventTime
|
||||||
|
// Assuming m contains necessary fields or we map carefully
|
||||||
|
const mappedMatch: Match = {
|
||||||
|
id: m.id
|
||||||
|
? m.id.toString()
|
||||||
|
: item.matchId
|
||||||
|
? item.matchId.toString()
|
||||||
|
: item.typeId,
|
||||||
|
league: m.leagueName || "",
|
||||||
|
time: m.eventTime || "",
|
||||||
|
home: m.eventHomeTeam || "",
|
||||||
|
away: m.eventAwayTeam || "",
|
||||||
|
scoreText: m.eventFinalResult || "0 - 0",
|
||||||
|
fav: true,
|
||||||
|
leagueId: m.leagueKey,
|
||||||
|
sportId: 1, // Default
|
||||||
|
isLive: false, // Provide default
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MatchCard
|
||||||
|
match={mappedMatch}
|
||||||
|
onFavoriteToggle={() => handleUnfavorite("match", item.typeId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGenericItem = (item: FavoriteItem) => {
|
||||||
|
let name = "";
|
||||||
|
let logo = "";
|
||||||
|
let desc = "";
|
||||||
|
|
||||||
|
if (activeTab === "team" && item.team) {
|
||||||
|
name = item.team.name;
|
||||||
|
logo = item.team.logo;
|
||||||
|
} else if (activeTab === "player" && item.player) {
|
||||||
|
name = item.player.name;
|
||||||
|
logo = item.player.photo || item.player.avatar;
|
||||||
|
desc = item.player.teamName;
|
||||||
|
} else {
|
||||||
|
name = t("favorites.unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView
|
||||||
|
style={[
|
||||||
|
styles.genericItem,
|
||||||
|
{ borderColor: isDark ? "#38383A" : "#E5E5EA" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.itemContent}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: logo || "https://via.placeholder.com/40" }}
|
||||||
|
style={{ width: 40, height: 40, borderRadius: 20, marginRight: 12 }}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
<View>
|
||||||
|
<ThemedText>{name}</ThemedText>
|
||||||
|
{desc ? (
|
||||||
|
<ThemedText style={{ fontSize: 12, color: "#8E8E93" }}>
|
||||||
|
{desc}
|
||||||
|
</ThemedText>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleUnfavorite(activeTab, item.typeId)}
|
||||||
|
>
|
||||||
|
<IconSymbol name="star" size={24} color="#FFD700" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type="title">Favorites</ThemedText>
|
{/* Header */}
|
||||||
|
<View style={[styles.header, { paddingTop: insets.top + 10 }]}>
|
||||||
|
<ThemedText type="title" style={{ fontSize: 28 }}>
|
||||||
|
{t("tabs.fav")}
|
||||||
|
</ThemedText>
|
||||||
|
<TouchableOpacity>
|
||||||
|
<IconSymbol name="create-outline" size={24} color={iconColor} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<View>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.tabContainer}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = activeTab === tab.key;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={tab.key}
|
||||||
|
style={[
|
||||||
|
styles.tabButton,
|
||||||
|
isActive && {
|
||||||
|
backgroundColor: isDark ? "#3A3A3C" : "#E5E5EA",
|
||||||
|
borderColor: isDark ? "#3A3A3C" : "#E5E5EA",
|
||||||
|
},
|
||||||
|
!isActive && {
|
||||||
|
borderColor: isDark ? "#38383A" : "#E5E5EA",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => setActiveTab(tab.key)}
|
||||||
|
>
|
||||||
|
<IconSymbol
|
||||||
|
name={tab.icon}
|
||||||
|
size={16}
|
||||||
|
color={isActive ? textColor : "#8E8E93"}
|
||||||
|
/>
|
||||||
|
<ThemedText
|
||||||
|
style={[
|
||||||
|
styles.tabText,
|
||||||
|
{ color: isActive ? textColor : "#8E8E93" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<FlatList
|
||||||
|
data={favorites}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
if (activeTab === "match") return renderMatchItem(item);
|
||||||
|
return renderGenericItem(item);
|
||||||
|
}}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
|
}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListFooterComponent={
|
||||||
|
loading && favorites.length > 0 ? (
|
||||||
|
<ActivityIndicator style={{ padding: 10 }} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
!loading ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<ThemedText style={{ color: "#8E8E93" }}>
|
||||||
|
{t("favorites.no_data")}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,7 +285,52 @@ export default function FavoriteScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 15,
|
||||||
|
},
|
||||||
|
tabContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 10,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
tabButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
tabText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 100,
|
||||||
|
},
|
||||||
|
genericItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
itemContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
padding: 40,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
"finished": "Finished",
|
"finished": "Finished",
|
||||||
"fav": "Favorites"
|
"fav": "Favorites"
|
||||||
},
|
},
|
||||||
|
"favorites": {
|
||||||
|
"filter_match": "Matches",
|
||||||
|
"filter_team": "Teams",
|
||||||
|
"filter_player": "Players",
|
||||||
|
"filter_league": "Leagues",
|
||||||
|
"no_data": "No favorites found",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
"finished": "已完成",
|
"finished": "已完成",
|
||||||
"fav": "收藏"
|
"fav": "收藏"
|
||||||
},
|
},
|
||||||
|
"favorites": {
|
||||||
|
"filter_match": "比赛",
|
||||||
|
"filter_team": "球队",
|
||||||
|
"filter_player": "球员",
|
||||||
|
"filter_league": "联赛",
|
||||||
|
"no_data": "暂无收藏",
|
||||||
|
"unknown": "未知"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "设置",
|
"title": "设置",
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
|
|||||||
23
lib/api.ts
23
lib/api.ts
@@ -6,6 +6,7 @@ import {
|
|||||||
AppleSignInResponse,
|
AppleSignInResponse,
|
||||||
Country,
|
Country,
|
||||||
FavoriteCheckResponse,
|
FavoriteCheckResponse,
|
||||||
|
FavoriteListResponse,
|
||||||
FavoriteRequest,
|
FavoriteRequest,
|
||||||
H2HData,
|
H2HData,
|
||||||
League,
|
League,
|
||||||
@@ -483,3 +484,25 @@ export const checkFavorite = async (
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchFavorites = async (
|
||||||
|
type: string,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 20
|
||||||
|
): Promise<FavoriteListResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ApiResponse<FavoriteListResponse>>(
|
||||||
|
API_ENDPOINTS.FAVORITES,
|
||||||
|
{
|
||||||
|
params: { type, page, pageSize },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.data.code === 0) {
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
throw new Error(response.data.message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fetch favorites error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
19
types/api.ts
19
types/api.ts
@@ -490,3 +490,22 @@ export interface UserProfile {
|
|||||||
nickname: string;
|
nickname: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FavoriteItem {
|
||||||
|
id: number;
|
||||||
|
type: string; // "match" | "team" | "player" | "league"
|
||||||
|
typeId: string;
|
||||||
|
matchId?: number;
|
||||||
|
teamId?: number;
|
||||||
|
playerId?: number;
|
||||||
|
match?: any;
|
||||||
|
team?: any;
|
||||||
|
player?: any;
|
||||||
|
notify: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FavoriteListResponse {
|
||||||
|
list: FavoriteItem[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user