添加赔率设置功能,支持选择博彩公司并展示赔率信息;优化状态管理和国际化文本

This commit is contained in:
yuchenglong
2026-01-19 17:24:09 +08:00
parent e1320a67e4
commit 7024b03c30
7 changed files with 439 additions and 51 deletions

View File

@@ -15,24 +15,59 @@ import {
import { ThemedText } from "@/components/themed-text";
import { IconSymbol } from "@/components/ui/icon-symbol";
import { useAppState } from "@/context/AppStateContext";
import { useTheme } from "@/context/ThemeContext";
import { changeLanguage } from "@/i18n";
import { appleSignIn, fetchUserProfile, logout } from "@/lib/api";
import { storage } from "@/lib/storage";
import type { UserProfile } from "@/types/api";
const BOOKMAKERS = [
"10Bet",
"WilliamHill",
"bet365",
"Marathon",
"Unibet",
"Betfair",
"188bet",
"Pncl",
"Sbo",
];
export default function ProfileScreen() {
const { theme, toggleTheme, setTheme, isSystemTheme, useSystemTheme } =
useTheme();
const { state, updateOddsSettings } = useAppState();
const { t, i18n } = useTranslation();
const router = useRouter();
const isDark = theme === "dark";
const [appleAvailable, setAppleAvailable] = React.useState(false);
const [user, setUser] = React.useState<UserProfile | null>(null);
const [loginModalVisible, setLoginModalVisible] = React.useState(false);
const [oddsModalVisible, setOddsModalVisible] = React.useState(false);
const currentLanguage = i18n.language;
const toggleOdds = () => {
updateOddsSettings({
...state.oddsSettings,
enabled: !state.oddsSettings.enabled,
});
};
const selectBookmaker = (name: string) => {
const current = state.oddsSettings.selectedBookmakers;
let next: string[];
if (current.includes(name)) {
next = current.filter((b) => b !== name);
} else {
next = [...current, name].slice(-2); // Keep last 2
}
updateOddsSettings({
...state.oddsSettings,
selectedBookmakers: next,
});
};
const toggleLanguage = () => {
const nextLang = currentLanguage.startsWith("en") ? "zh" : "en";
changeLanguage(nextLang);
@@ -65,7 +100,8 @@ export default function ProfileScreen() {
platformVersion,
},
user:
credential.fullName && (credential.fullName.givenName || credential.fullName.familyName)
credential.fullName &&
(credential.fullName.givenName || credential.fullName.familyName)
? {
name: {
firstName: credential.fullName.givenName || undefined,
@@ -178,7 +214,9 @@ export default function ProfileScreen() {
<>
<View style={styles.profileHeader}>
<Image
source={{ uri: user?.avatar || "https://via.placeholder.com/100" }}
source={{
uri: user?.avatar || "https://via.placeholder.com/100",
}}
style={styles.avatar}
contentFit="cover"
/>
@@ -201,7 +239,9 @@ export default function ProfileScreen() {
]}
onPress={handleLogout}
>
<ThemedText style={{ color: "#FF3B30" }}></ThemedText>
<ThemedText style={{ color: "#FF3B30" }}>
{t("settings.logout")}
</ThemedText>
</TouchableOpacity>
</>
) : (
@@ -212,9 +252,11 @@ export default function ProfileScreen() {
>
<View style={styles.loginIcon} />
<View style={styles.loginInfo}>
<ThemedText style={styles.loginTitle}></ThemedText>
<ThemedText style={styles.loginTitle}>
{t("settings.login")}
</ThemedText>
<ThemedText style={{ color: subTextColor }}>
{t("settings.click_to_login")}
</ThemedText>
</View>
</TouchableOpacity>
@@ -310,6 +352,83 @@ export default function ProfileScreen() {
</View>
</View>
<ThemedText style={styles.sectionTitle}>
{t("settings.odds_title")}
</ThemedText>
<View
style={[
styles.section,
{ backgroundColor: isDark ? "#1c1c1e" : "#fff" },
]}
>
<View style={styles.settingItem}>
<View style={styles.settingLabel}>
<IconSymbol
name="stats-chart"
size={20}
color={iconColor}
style={{ marginRight: 10 }}
/>
<ThemedText>{t("settings.odds_show")}</ThemedText>
</View>
<View style={styles.settingControl}>
<TouchableOpacity onPress={toggleOdds} style={styles.button}>
<ThemedText>
{state.oddsSettings.enabled
? t("settings.odds_enabled")
: t("settings.odds_disabled")}
</ThemedText>
</TouchableOpacity>
</View>
</View>
<View
style={[
styles.settingItem,
{
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: isDark ? "#38383a" : "#c6c6c8",
},
]}
>
<TouchableOpacity
style={styles.settingItemContent}
onPress={() => setOddsModalVisible(true)}
disabled={!state.oddsSettings.enabled}
>
<View style={styles.settingLabel}>
<IconSymbol
name="list"
size={20}
color={state.oddsSettings.enabled ? iconColor : subTextColor}
style={{ marginRight: 10 }}
/>
<ThemedText
style={{
color: state.oddsSettings.enabled
? textColor
: subTextColor,
}}
>
{t("settings.odds_select_company")}
</ThemedText>
</View>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<ThemedText style={{ color: subTextColor, marginRight: 4 }}>
{state.oddsSettings.selectedBookmakers.join(", ") ||
t("settings.odds_unselected")}
</ThemedText>
<IconSymbol
name="chevron-forward"
size={16}
color={subTextColor}
/>
</View>
</TouchableOpacity>
</View>
</View>
{/* <ThemedText style={styles.sectionTitle}>登录</ThemedText>
<View
@@ -367,7 +486,9 @@ export default function ProfileScreen() {
{ backgroundColor: isDark ? "#1c1c1e" : "#fff" },
]}
>
<ThemedText style={styles.modalTitle}></ThemedText>
<ThemedText style={styles.modalTitle}>
{t("settings.select_login_method")}
</ThemedText>
{appleAvailable && (
<AppleAuthentication.AppleAuthenticationButton
buttonType={
@@ -384,13 +505,76 @@ export default function ProfileScreen() {
/>
)}
<TouchableOpacity style={styles.googleButton} onPress={() => {}}>
<ThemedText>Google </ThemedText>
<ThemedText>{t("settings.google_login")}</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={styles.modalCancel}
onPress={() => setLoginModalVisible(false)}
>
<ThemedText></ThemedText>
<ThemedText>{t("settings.cancel")}</ThemedText>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
<Modal
visible={oddsModalVisible}
transparent
animationType="slide"
onRequestClose={() => setOddsModalVisible(false)}
>
<TouchableOpacity
style={styles.modalMask}
activeOpacity={1}
onPress={() => setOddsModalVisible(false)}
>
<View
style={[
styles.modalCard,
{
backgroundColor: isDark ? "#1c1c1e" : "#fff",
maxHeight: "70%",
},
]}
>
<ThemedText style={styles.modalTitle}>
{t("settings.odds_modal_title")}
</ThemedText>
<ScrollView style={{ marginVertical: 10 }}>
{BOOKMAKERS.map((name) => {
const isSelected =
state.oddsSettings.selectedBookmakers.includes(name);
return (
<TouchableOpacity
key={name}
style={[
styles.bookmakerItem,
{ borderColor: isDark ? "#38383a" : "#eee" },
]}
onPress={() => selectBookmaker(name)}
>
<ThemedText
style={{
color: isSelected ? "#FF9500" : textColor,
fontWeight: isSelected ? "bold" : "normal",
}}
>
{name}
</ThemedText>
{isSelected && (
<IconSymbol name="checkmark" size={18} color="#FF9500" />
)}
</TouchableOpacity>
);
})}
</ScrollView>
<TouchableOpacity
style={styles.modalCancel}
onPress={() => setOddsModalVisible(false)}
>
<ThemedText type="defaultSemiBold">
{t("settings.odds_confirm")}
</ThemedText>
</TouchableOpacity>
</View>
</TouchableOpacity>
@@ -502,6 +686,13 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "center",
},
bookmakerItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
},
logoutButton: {
paddingVertical: 12,
alignItems: "center",

View File

@@ -1,13 +1,14 @@
import { ThemedText } from "@/components/themed-text";
import { IconSymbol } from "@/components/ui/icon-symbol";
import { Colors } from "@/constants/theme";
import { useAppState } from "@/context/AppStateContext";
import { useTheme } from "@/context/ThemeContext";
import { addFavorite, removeFavorite } from "@/lib/api";
import { Match } from "@/types/api";
import { addFavorite, fetchOdds, removeFavorite } from "@/lib/api";
import { Match, OddsItem } from "@/types/api";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Pressable, StyleSheet, TouchableOpacity, View } from "react-native";
interface MatchCardProps {
@@ -23,12 +24,36 @@ export function MatchCard({
}: MatchCardProps) {
const router = useRouter();
const { theme } = useTheme();
const { state } = useAppState();
const [isFav, setIsFav] = useState(match.fav);
const [loading, setLoading] = useState(false);
const [odds, setOdds] = useState<OddsItem[]>(match.odds || []);
// console.log("MatchCard render:", JSON.stringify(match));
const oddsSettings = state.oddsSettings;
useEffect(() => {
if (
oddsSettings.enabled &&
oddsSettings.selectedBookmakers.length > 0 &&
!match.odds
) {
fetchOdds(match.sportId || 1, parseInt(match.id))
.then((res) => {
const matchOdds = res[match.id]?.data || [];
setOdds(matchOdds);
})
.catch((err) => console.log("Fetch match card odds error:", err));
}
}, [
oddsSettings.enabled,
oddsSettings.selectedBookmakers,
match.id,
match.odds,
]);
// 当外部传入的 match.fav 改变时,更新内部状态
React.useEffect(() => {
useEffect(() => {
setIsFav(match.fav);
}, [match.fav]);
@@ -39,6 +64,8 @@ export function MatchCard({
const scoreBorder = isDark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.12)";
const scoreBg = isDark ? "rgba(255,255,255,0.04)" : "rgba(255,255,255,0.6)";
const oddBadgeBg = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)";
const isLive = React.useMemo(() => {
return !!match.isLive;
}, [match.isLive]);
@@ -149,8 +176,9 @@ export function MatchCard({
</ThemedText>
</View>
{/* Middle: Teams */}
{/* Middle: Teams & Odds */}
<View style={styles.middle}>
<View style={styles.teamContainer}>
<View style={styles.teamRow}>
{match.homeTeamLogo ? (
<Image
@@ -187,6 +215,66 @@ export function MatchCard({
</View>
</View>
{/* Odds Section */}
{oddsSettings.enabled && odds.length > 0 && (
<View style={styles.oddsContainer}>
{oddsSettings.selectedBookmakers.map((bookmaker, idx) => {
const item = odds.find((o) => o.odd_bookmakers === bookmaker);
if (!item) return null;
// Pick 3 values to display. Using odd_1, odd_x, odd_2 for example.
// Or try to match the screenshot's 3-column style.
const val1 = item.odd_1 || item.ah0_1 || "-";
const val2 = item.odd_x || "0" || "-";
const val3 = item.odd_2 || item.ah0_2 || "-";
return (
<View key={bookmaker} style={styles.bookmakerOddsRow}>
<View
style={[styles.oddBadge, { backgroundColor: oddBadgeBg }]}
>
<ThemedText
style={[
styles.oddText,
{ color: isDark ? "#fff" : "#000" },
idx === 0 && styles.oddTextHighlight,
]}
>
{val1}
</ThemedText>
</View>
<View
style={[styles.oddBadge, { backgroundColor: oddBadgeBg }]}
>
<ThemedText
style={[
styles.oddText,
{ color: isDark ? "#fff" : "#000" },
idx === 0 && styles.oddTextHighlight,
]}
>
{val2}
</ThemedText>
</View>
<View
style={[styles.oddBadge, { backgroundColor: oddBadgeBg }]}
>
<ThemedText
style={[
styles.oddText,
{ color: isDark ? "#fff" : "#000" },
idx === 0 && styles.oddTextHighlight,
]}
>
{val3}
</ThemedText>
</View>
</View>
);
})}
</View>
)}
</View>
{/* Right: Score box + favorite */}
<View style={styles.right}>
{scoreParts.hasScore ? (
@@ -270,6 +358,14 @@ const styles = StyleSheet.create({
fontWeight: "bold",
},
middle: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
minWidth: 0,
},
teamContainer: {
flex: 1,
justifyContent: "center",
gap: 8,
@@ -289,6 +385,31 @@ const styles = StyleSheet.create({
lineHeight: 18,
flex: 1,
},
oddsContainer: {
gap: 8,
alignItems: "flex-end",
},
bookmakerOddsRow: {
flexDirection: "row",
gap: 4,
},
oddBadge: {
backgroundColor: "rgba(0,0,0,0.03)",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
minWidth: 40,
alignItems: "center",
},
oddText: {
fontSize: 11,
fontWeight: "600",
opacity: 0.8,
},
oddTextHighlight: {
color: "#FF9500",
opacity: 1,
},
right: {
flexDirection: "row",
alignItems: "center",

View File

@@ -1,10 +1,18 @@
import React, { createContext, ReactNode, useContext, useState } from "react";
import { OddsSettings, storage } from "@/lib/storage";
import React, {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react";
interface AppState {
selectedSportId: number | null;
selectedDate: Date;
selectedLeagueKey: string | null;
timezone: string;
oddsSettings: OddsSettings;
}
interface AppStateContextType {
@@ -13,10 +21,11 @@ interface AppStateContextType {
updateDate: (date: Date) => void;
updateLeagueKey: (leagueKey: string | null) => void;
updateTimezone: (timezone: string) => void;
updateOddsSettings: (settings: OddsSettings) => void;
}
const AppStateContext = createContext<AppStateContextType | undefined>(
undefined
undefined,
);
export function AppStateProvider({ children }: { children: ReactNode }) {
@@ -25,8 +34,16 @@ export function AppStateProvider({ children }: { children: ReactNode }) {
selectedDate: new Date(),
selectedLeagueKey: null,
timezone: "UTC",
oddsSettings: { enabled: false, selectedBookmakers: [] },
});
useEffect(() => {
// Initial load of odds settings
storage.getOddsSettings().then((settings) => {
setState((prev) => ({ ...prev, oddsSettings: settings }));
});
}, []);
const updateSportId = (sportId: number | null) => {
setState((prev) => ({ ...prev, selectedSportId: sportId }));
};
@@ -43,6 +60,11 @@ export function AppStateProvider({ children }: { children: ReactNode }) {
setState((prev) => ({ ...prev, timezone }));
};
const updateOddsSettings = (settings: OddsSettings) => {
setState((prev) => ({ ...prev, oddsSettings: settings }));
storage.setOddsSettings(settings);
};
return (
<AppStateContext.Provider
value={{
@@ -51,6 +73,7 @@ export function AppStateProvider({ children }: { children: ReactNode }) {
updateDate,
updateLeagueKey,
updateTimezone,
updateOddsSettings,
}}
>
{children}

View File

@@ -23,7 +23,21 @@
"dark": "Dark",
"system": "System",
"english": "English",
"chinese": "Chinese"
"chinese": "Chinese",
"odds_title": "Odds Settings",
"odds_show": "Show Odds",
"odds_select_company": "Select Bookmakers (Max 2)",
"odds_enabled": "On",
"odds_disabled": "Off",
"odds_unselected": "Unselected",
"odds_modal_title": "Select Bookmakers (Max 2)",
"odds_confirm": "Confirm",
"login": "Login",
"click_to_login": "Click to login",
"logout": "Logout",
"select_login_method": "Select login method",
"cancel": "Cancel",
"google_login": "Google Login"
},
"home": {
"title": "ScoreNow",

View File

@@ -23,7 +23,21 @@
"dark": "深色",
"system": "跟随系统",
"english": "英文",
"chinese": "中文"
"chinese": "中文",
"odds_title": "赔率设置",
"odds_show": "展示赔率",
"odds_select_company": "选择公司 (最多2个)",
"odds_enabled": "开启",
"odds_disabled": "关闭",
"odds_unselected": "未选择",
"odds_modal_title": "选择赔率公司 (最多2项)",
"odds_confirm": "确定",
"login": "登录",
"click_to_login": "点击登录",
"logout": "登出",
"select_login_method": "选择登录方式",
"cancel": "取消",
"google_login": "Google 登录"
},
"home": {
"title": "ScoreNow",

View File

@@ -1,12 +1,18 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { UserProfile } from "@/types/api";
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEYS = {
ACCESS_TOKEN: "access_token",
REFRESH_TOKEN: "refresh_token",
USER: "user",
ODDS_SETTINGS: "odds_settings",
};
export interface OddsSettings {
enabled: boolean;
selectedBookmakers: string[];
}
export const storage = {
async setAccessToken(token: string): Promise<void> {
await AsyncStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, token);
@@ -38,11 +44,29 @@ export const storage = {
}
},
async setOddsSettings(settings: OddsSettings): Promise<void> {
await AsyncStorage.setItem(
STORAGE_KEYS.ODDS_SETTINGS,
JSON.stringify(settings),
);
},
async getOddsSettings(): Promise<OddsSettings> {
const settingsStr = await AsyncStorage.getItem(STORAGE_KEYS.ODDS_SETTINGS);
if (!settingsStr) return { enabled: false, selectedBookmakers: [] };
try {
return JSON.parse(settingsStr) as OddsSettings;
} catch {
return { enabled: false, selectedBookmakers: [] };
}
},
async clear(): Promise<void> {
await AsyncStorage.multiRemove([
STORAGE_KEYS.ACCESS_TOKEN,
STORAGE_KEYS.REFRESH_TOKEN,
STORAGE_KEYS.USER,
STORAGE_KEYS.ODDS_SETTINGS,
]);
},
};

View File

@@ -36,6 +36,7 @@ export interface Match {
leagueId?: number;
isLive?: boolean;
meta?: string;
odds?: OddsItem[];
}
export interface LiveScoreMatch {