Files
physical-expo/app/profile.tsx
2026-01-20 13:58:42 +08:00

819 lines
24 KiB
TypeScript

import * as AppleAuthentication from "expo-apple-authentication";
import Constants from "expo-constants";
import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import React from "react";
import { useTranslation } from "react-i18next";
import {
Modal,
Platform,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
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, SUPPORTED_LANGUAGES } 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, updateCardsSettings } = 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 [languageModalVisible, setLanguageModalVisible] = React.useState(false);
const currentLanguage = i18n.language;
const toggleOdds = () => {
updateOddsSettings({
...state.oddsSettings,
enabled: !state.oddsSettings.enabled,
});
};
const toggleCards = () => {
updateCardsSettings({
...state.cardsSettings,
enabled: !state.cardsSettings.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 selectLanguage = (langCode: string) => {
changeLanguage(langCode);
setLanguageModalVisible(false);
};
const getCurrentLanguageName = () => {
const lang = SUPPORTED_LANGUAGES.find((l) =>
currentLanguage.startsWith(l.code)
);
return lang?.name || "English";
};
const handleAppleSignIn = async () => {
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
console.log("Apple credential:", {
fullName: credential.fullName,
email: credential.email,
user: credential.user,
});
const deviceId = Constants.deviceId || Constants.sessionId || "unknown";
const platformVersion = Platform.Version.toString();
const options = {
authorizationCode: credential.authorizationCode || "",
identityToken: credential.identityToken || "",
deviceInfo: {
deviceId,
platform: Platform.OS,
platformVersion,
},
user:
credential.fullName &&
(credential.fullName.givenName || credential.fullName.familyName)
? {
name: {
firstName: credential.fullName.givenName || undefined,
lastName: credential.fullName.familyName || undefined,
},
}
: undefined,
};
console.log("登录参数", options);
const res = await appleSignIn(options);
console.log("登录响应", res);
await storage.setAccessToken(res.accessToken);
await storage.setRefreshToken(res.refreshToken);
if (res.user) {
await storage.setUser(res.user);
setUser(res.user);
} else {
const profile = await fetchUserProfile();
await storage.setUser(profile);
setUser(profile);
}
setLoginModalVisible(false);
} catch (error: any) {
if (error?.code === "ERR_REQUEST_CANCELED") {
return;
}
console.error("Apple sign in error:", error);
}
};
const handleLoginPress = () => {
setLoginModalVisible(true);
};
React.useEffect(() => {
AppleAuthentication.isAvailableAsync().then(setAppleAvailable);
}, []);
React.useEffect(() => {
const loadUser = async () => {
const savedUser = await storage.getUser();
if (savedUser) {
setUser(savedUser);
try {
const profile = await fetchUserProfile();
await storage.setUser(profile);
setUser(profile);
} catch {
setUser(savedUser);
}
}
};
loadUser();
}, []);
const handleLogout = async () => {
try {
await logout();
} catch (error) {
console.error("Logout error:", error);
} finally {
await storage.clear();
setUser(null);
}
};
const isLoggedIn = !!user;
const iconColor = isDark ? "#FFFFFF" : "#000000";
const textColor = isDark ? "#FFFFFF" : "#000000";
const subTextColor = isDark ? "#AAAAAA" : "#666666";
return (
<>
<Stack.Screen
options={{
title: t("profile.title"),
headerShown: true,
headerBackTitle: t("settings.back"),
// Ensure header matches theme to avoid white flash
headerStyle: {
backgroundColor: isDark ? "#000" : "#f2f2f7",
},
headerTintColor: textColor,
headerShadowVisible: false,
// Present the screen as a normal card and slide from right
presentation: "card",
animation: "slide_from_right",
// Set the scene/content background to match theme during transition
contentStyle: { backgroundColor: isDark ? "#000" : "#f2f2f7" },
}}
/>
<ScrollView
style={[
styles.container,
{ backgroundColor: isDark ? "#000" : "#f2f2f7" },
]}
>
<View
style={[
styles.section,
{ backgroundColor: isDark ? "#1c1c1e" : "#fff" },
]}
>
{isLoggedIn ? (
<>
<View style={styles.profileHeader}>
<Image
source={{
uri: user?.avatar || "https://via.placeholder.com/100",
}}
style={styles.avatar}
contentFit="cover"
/>
<View style={styles.profileInfo}>
<ThemedText type="title">
{user?.nickname || t("profile.name")}
</ThemedText>
{/* <ThemedText style={{ color: subTextColor }}>
{user?.appleId || ""}
</ThemedText> */}
</View>
</View>
<TouchableOpacity
style={[
styles.logoutButton,
{
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: isDark ? "#38383a" : "#c6c6c8",
},
]}
onPress={handleLogout}
>
<ThemedText style={{ color: "#FF3B30" }}>
{t("settings.logout")}
</ThemedText>
</TouchableOpacity>
</>
) : (
<TouchableOpacity
style={styles.loginCard}
onPress={handleLoginPress}
activeOpacity={0.8}
>
<View style={styles.loginIcon} />
<View style={styles.loginInfo}>
<ThemedText style={styles.loginTitle}>
{t("settings.login")}
</ThemedText>
<ThemedText style={{ color: subTextColor }}>
{t("settings.click_to_login")}
</ThemedText>
</View>
</TouchableOpacity>
)}
</View>
<ThemedText style={styles.sectionTitle}>
{t("settings.title")}
</ThemedText>
<View
style={[
styles.section,
{ backgroundColor: isDark ? "#1c1c1e" : "#fff" },
]}
>
<View style={styles.settingItem}>
<View style={styles.settingLabel}>
<IconSymbol
name="sunny"
size={20}
color={iconColor}
style={{ marginRight: 10 }}
/>
<ThemedText>{t("settings.theme")}</ThemedText>
</View>
<View style={styles.settingControl}>
<TouchableOpacity onPress={toggleTheme} style={styles.button}>
<ThemedText>
{theme === "light" ? t("settings.light") : t("settings.dark")}
</ThemedText>
</TouchableOpacity>
</View>
</View>
<View
style={[
styles.settingItem,
{
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: isDark ? "#38383a" : "#c6c6c8",
},
]}
>
<TouchableOpacity
style={styles.settingItemContent}
onPress={() => setLanguageModalVisible(true)}
>
<View style={styles.settingLabel}>
<IconSymbol
name="globe"
size={20}
color={iconColor}
style={{ marginRight: 10 }}
/>
<ThemedText>{t("settings.language")}</ThemedText>
</View>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<ThemedText style={{ color: subTextColor, marginRight: 4 }}>
{getCurrentLanguageName()}
</ThemedText>
<IconSymbol
name="chevron-forward"
size={16}
color={subTextColor}
/>
</View>
</TouchableOpacity>
</View>
<View
style={[
styles.settingItem,
{
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: isDark ? "#38383a" : "#c6c6c8",
},
]}
>
<TouchableOpacity
style={styles.settingItemContent}
onPress={() => router.push("/privacy" as any)}
>
<View style={styles.settingLabel}>
<IconSymbol
name="document"
size={20}
color={iconColor}
style={{ marginRight: 10 }}
/>
<ThemedText>{t("profile.privacy")}</ThemedText>
</View>
<IconSymbol
name="chevron-forward"
size={16}
color={subTextColor}
/>
</TouchableOpacity>
</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}>
{t("settings.cards_title")}
</ThemedText>
<View
style={[
styles.section,
{ backgroundColor: isDark ? "#1c1c1e" : "#fff" },
]}
>
<View style={styles.settingItem}>
<View style={styles.settingLabel}>
<IconSymbol
name="id-card"
size={20}
color={iconColor}
style={{ marginRight: 10 }}
/>
<ThemedText>{t("settings.cards_show")}</ThemedText>
</View>
<View style={styles.settingControl}>
<TouchableOpacity onPress={toggleCards} style={styles.button}>
<ThemedText>
{state.cardsSettings.enabled
? t("settings.cards_enabled")
: t("settings.cards_disabled")}
</ThemedText>
</TouchableOpacity>
</View>
</View>
</View>
{/* <ThemedText style={styles.sectionTitle}>登录</ThemedText>
<View
style={[
styles.section,
{ backgroundColor: isDark ? "#1c1c1e" : "#fff" },
]}
>
{appleAvailable && (
<View style={styles.settingItem}>
<AppleAuthentication.AppleAuthenticationButton
buttonType={
AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN
}
buttonStyle={
isDark
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
}
cornerRadius={8}
style={{ width: "100%", height: 44 }}
onPress={handleAppleSignIn}
/>
</View>
)}
<View
style={[
styles.settingItem,
{
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: isDark ? "#38383a" : "#c6c6c8",
},
]}
>
<TouchableOpacity style={styles.googleButton} onPress={() => {}}>
<ThemedText>Google 登录</ThemedText>
</TouchableOpacity>
</View>
</View> */}
</ScrollView>
<Modal
visible={loginModalVisible}
transparent
animationType="fade"
onRequestClose={() => setLoginModalVisible(false)}
>
<TouchableOpacity
style={styles.modalMask}
activeOpacity={1}
onPress={() => setLoginModalVisible(false)}
>
<View
style={[
styles.modalCard,
{ backgroundColor: isDark ? "#1c1c1e" : "#fff" },
]}
>
<ThemedText style={styles.modalTitle}>
{t("settings.select_login_method")}
</ThemedText>
{appleAvailable && (
<AppleAuthentication.AppleAuthenticationButton
buttonType={
AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN
}
buttonStyle={
isDark
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
}
cornerRadius={8}
style={{ width: "100%", height: 44 }}
onPress={handleAppleSignIn}
/>
)}
<TouchableOpacity style={styles.googleButton} onPress={() => { }}>
<ThemedText>{t("settings.google_login")}</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={styles.modalCancel}
onPress={() => setLoginModalVisible(false)}
>
<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>
</Modal>
<Modal
visible={languageModalVisible}
transparent
animationType="slide"
onRequestClose={() => setLanguageModalVisible(false)}
>
<TouchableOpacity
style={styles.modalMask}
activeOpacity={1}
onPress={() => setLanguageModalVisible(false)}
>
<View
style={[
styles.modalCard,
{
backgroundColor: isDark ? "#1c1c1e" : "#fff",
maxHeight: "70%",
},
]}
>
<ThemedText style={styles.modalTitle}>
{t("settings.language")}
</ThemedText>
<ScrollView style={{ marginVertical: 10 }}>
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = currentLanguage.startsWith(lang.code);
return (
<TouchableOpacity
key={lang.code}
style={[
styles.bookmakerItem,
{ borderColor: isDark ? "#38383a" : "#eee" },
]}
onPress={() => selectLanguage(lang.code)}
>
<ThemedText
style={{
color: isSelected ? "#FF9500" : textColor,
fontWeight: isSelected ? "bold" : "normal",
}}
>
{lang.name}
</ThemedText>
{isSelected && (
<IconSymbol name="checkmark" size={18} color="#FF9500" />
)}
</TouchableOpacity>
);
})}
</ScrollView>
<TouchableOpacity
style={styles.modalCancel}
onPress={() => setLanguageModalVisible(false)}
>
<ThemedText type="defaultSemiBold">
{t("settings.cancel")}
</ThemedText>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
section: {
marginTop: 20,
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 10, // iOS style groups
marginHorizontal: 16,
},
sectionTitle: {
marginLeft: 32,
marginTop: 20,
marginBottom: 5,
fontSize: 13,
textTransform: "uppercase",
opacity: 0.6,
},
profileHeader: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 10,
},
avatar: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: "#ccc",
},
profileInfo: {
marginLeft: 15,
},
settingItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 12,
},
settingItemContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
flex: 1,
},
settingLabel: {
flexDirection: "row",
alignItems: "center",
},
settingControl: {
flexDirection: "row",
alignItems: "center",
},
button: {
paddingHorizontal: 10,
paddingVertical: 5,
},
googleButton: {
width: "100%",
height: 44,
borderRadius: 8,
borderWidth: 1,
borderColor: "#c6c6c8",
alignItems: "center",
justifyContent: "center",
},
loginCard: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
},
loginIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: "#c6c6c8",
},
loginInfo: {
marginLeft: 12,
},
loginTitle: {
fontSize: 18,
},
modalMask: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.4)",
justifyContent: "center",
paddingHorizontal: 24,
},
modalCard: {
borderRadius: 12,
padding: 16,
gap: 12,
},
modalTitle: {
fontSize: 16,
textAlign: "center",
},
modalCancel: {
height: 40,
alignItems: "center",
justifyContent: "center",
},
bookmakerItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
},
logoutButton: {
paddingVertical: 12,
alignItems: "center",
justifyContent: "center",
marginTop: 10,
},
});