diff --git a/app/profile.tsx b/app/profile.tsx index ac304fd..b0b5a28 100644 --- a/app/profile.tsx +++ b/app/profile.tsx @@ -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(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() { <> @@ -201,7 +239,9 @@ export default function ProfileScreen() { ]} onPress={handleLogout} > - 登出 + + {t("settings.logout")} + ) : ( @@ -212,9 +252,11 @@ export default function ProfileScreen() { > - 登录 + + {t("settings.login")} + - 点击登录 + {t("settings.click_to_login")} @@ -310,6 +352,83 @@ export default function ProfileScreen() { + + {t("settings.odds_title")} + + + + + + + {t("settings.odds_show")} + + + + + {state.oddsSettings.enabled + ? t("settings.odds_enabled") + : t("settings.odds_disabled")} + + + + + + + setOddsModalVisible(true)} + disabled={!state.oddsSettings.enabled} + > + + + + {t("settings.odds_select_company")} + + + + + {state.oddsSettings.selectedBookmakers.join(", ") || + t("settings.odds_unselected")} + + + + + + + {/* 登录 - 选择登录方式 + + {t("settings.select_login_method")} + {appleAvailable && ( )} {}}> - Google 登录 + {t("settings.google_login")} setLoginModalVisible(false)} > - 取消 + {t("settings.cancel")} + + + + + + setOddsModalVisible(false)} + > + setOddsModalVisible(false)} + > + + + {t("settings.odds_modal_title")} + + + {BOOKMAKERS.map((name) => { + const isSelected = + state.oddsSettings.selectedBookmakers.includes(name); + return ( + selectBookmaker(name)} + > + + {name} + + {isSelected && ( + + )} + + ); + })} + + setOddsModalVisible(false)} + > + + {t("settings.odds_confirm")} + @@ -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", diff --git a/components/match-card.tsx b/components/match-card.tsx index d84f534..45e960a 100644 --- a/components/match-card.tsx +++ b/components/match-card.tsx @@ -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(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,42 +176,103 @@ export function MatchCard({ - {/* Middle: Teams */} + {/* Middle: Teams & Odds */} - - {match.homeTeamLogo ? ( - - ) : null} - - {match.homeTeamName || match.home} - - - - {match.awayTeamLogo ? ( - - ) : null} - - {match.awayTeamName || match.away} - + + + {match.homeTeamLogo ? ( + + ) : null} + + {match.homeTeamName || match.home} + + + + {match.awayTeamLogo ? ( + + ) : null} + + {match.awayTeamName || match.away} + + + + {/* Odds Section */} + {oddsSettings.enabled && odds.length > 0 && ( + + {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 ( + + + + {val1} + + + + + {val2} + + + + + {val3} + + + + ); + })} + + )} {/* Right: Score box + favorite */} @@ -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", diff --git a/context/AppStateContext.tsx b/context/AppStateContext.tsx index 4215cd2..097b06f 100644 --- a/context/AppStateContext.tsx +++ b/context/AppStateContext.tsx @@ -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( - 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 ( {children} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 24b4e36..486c7b1 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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", diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 2800ee5..d55c902 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -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", diff --git a/lib/storage.ts b/lib/storage.ts index e3358d4..ae6ce72 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -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 { await AsyncStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, token); @@ -38,11 +44,29 @@ export const storage = { } }, + async setOddsSettings(settings: OddsSettings): Promise { + await AsyncStorage.setItem( + STORAGE_KEYS.ODDS_SETTINGS, + JSON.stringify(settings), + ); + }, + + async getOddsSettings(): Promise { + 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 { await AsyncStorage.multiRemove([ STORAGE_KEYS.ACCESS_TOKEN, STORAGE_KEYS.REFRESH_TOKEN, STORAGE_KEYS.USER, + STORAGE_KEYS.ODDS_SETTINGS, ]); }, }; diff --git a/types/api.ts b/types/api.ts index 9631f43..18b3178 100644 --- a/types/api.ts +++ b/types/api.ts @@ -36,6 +36,7 @@ export interface Match { leagueId?: number; isLive?: boolean; meta?: string; + odds?: OddsItem[]; } export interface LiveScoreMatch {