557 lines
16 KiB
TypeScript
557 lines
16 KiB
TypeScript
import { FC, useCallback, useEffect, useRef, useState } from "react"
|
|
import {
|
|
ActivityIndicator,
|
|
Animated,
|
|
TextStyle,
|
|
TouchableOpacity,
|
|
View,
|
|
ViewStyle,
|
|
} from "react-native"
|
|
import * as Application from "expo-application"
|
|
import * as Clipboard from "expo-clipboard"
|
|
import * as ImagePicker from "expo-image-picker"
|
|
|
|
import { Avatar } from "@/components/Avatar"
|
|
import { Button } from "@/components/Button"
|
|
import { Icon, PressableIcon } from "@/components/Icon"
|
|
import { ListItem } from "@/components/ListItem"
|
|
import { Modal } from "@/components/Modal"
|
|
import { Screen } from "@/components/Screen"
|
|
import { Text } from "@/components/Text"
|
|
import { TextField } from "@/components/TextField"
|
|
import { useAuth } from "@/context/AuthContext"
|
|
import { translate } from "@/i18n/translate"
|
|
import { AppStackScreenProps } from "@/navigators/navigationTypes"
|
|
import { uploadFile } from "@/services/api/uploadApi"
|
|
import { useAppTheme } from "@/theme/context"
|
|
import { $styles } from "@/theme/styles"
|
|
import type { ThemedStyle } from "@/theme/types"
|
|
import { s } from "@/utils/responsive"
|
|
import { useHeader } from "@/utils/useHeader"
|
|
|
|
export const ProfileScreen: FC<AppStackScreenProps<"Profile">> = function ProfileScreen({
|
|
navigation,
|
|
}) {
|
|
const { user, updateProfile, isLoading, error, clearError } = useAuth()
|
|
const { themed, theme } = useAppTheme()
|
|
const copyToastAnim = useRef(new Animated.Value(0)).current
|
|
const copyToastTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (copyToastTimeout.current) {
|
|
clearTimeout(copyToastTimeout.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const showCopyToast = useCallback(() => {
|
|
if (copyToastTimeout.current) {
|
|
clearTimeout(copyToastTimeout.current)
|
|
}
|
|
|
|
copyToastAnim.setValue(0)
|
|
Animated.timing(copyToastAnim, {
|
|
toValue: 1,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}).start()
|
|
|
|
copyToastTimeout.current = setTimeout(() => {
|
|
Animated.timing(copyToastAnim, {
|
|
toValue: 0,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}).start()
|
|
}, 1600)
|
|
}, [copyToastAnim])
|
|
|
|
// Copy to clipboard
|
|
const copyToClipboard = useCallback(
|
|
async (text: string) => {
|
|
const normalizedText = text.trim()
|
|
if (!normalizedText) return
|
|
await Clipboard.setStringAsync(normalizedText)
|
|
showCopyToast()
|
|
},
|
|
[showCopyToast],
|
|
)
|
|
|
|
// Edit profile modal state
|
|
const [isEditProfileVisible, setIsEditProfileVisible] = useState(false)
|
|
const [nickname, setNickname] = useState(user?.profile?.nickname || "")
|
|
const [uploadedAvatarUrl, setUploadedAvatarUrl] = useState<string | null>(null)
|
|
const [isUploading, setIsUploading] = useState(false)
|
|
|
|
// Check if there are any changes to save
|
|
const hasChanges =
|
|
nickname.trim() !== (user?.profile?.nickname || "") || uploadedAvatarUrl !== null
|
|
|
|
// Open edit profile modal
|
|
const openEditProfile = useCallback(() => {
|
|
setNickname(user?.profile?.nickname || "")
|
|
setUploadedAvatarUrl(null)
|
|
clearError()
|
|
setIsEditProfileVisible(true)
|
|
}, [user?.profile?.nickname, clearError])
|
|
|
|
// Pick image from library and upload immediately
|
|
const pickImage = useCallback(async () => {
|
|
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync()
|
|
|
|
if (!permissionResult.granted) {
|
|
return
|
|
}
|
|
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
allowsEditing: true,
|
|
aspect: [1, 1],
|
|
quality: 0.8,
|
|
})
|
|
|
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
|
const asset = result.assets[0]
|
|
const uri = asset.uri
|
|
const fileName = asset.fileName || `avatar_${Date.now()}.jpg`
|
|
const mimeType = asset.mimeType || "image/jpeg"
|
|
|
|
// Upload immediately after selection
|
|
setIsUploading(true)
|
|
try {
|
|
const uploadResult = await uploadFile(uri, fileName, mimeType)
|
|
|
|
if (uploadResult.kind === "ok") {
|
|
setUploadedAvatarUrl(uploadResult.url)
|
|
} else {
|
|
console.error("Upload failed:", uploadResult.message)
|
|
}
|
|
} finally {
|
|
setIsUploading(false)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
// Save profile (avatar already uploaded, just update profile)
|
|
const handleSaveProfile = useCallback(async () => {
|
|
if (!nickname.trim()) return
|
|
|
|
const success = await updateProfile({
|
|
nickname: nickname.trim(),
|
|
...(uploadedAvatarUrl && { avatar: uploadedAvatarUrl }),
|
|
})
|
|
|
|
if (success) {
|
|
setIsEditProfileVisible(false)
|
|
setUploadedAvatarUrl(null)
|
|
}
|
|
}, [nickname, uploadedAvatarUrl, updateProfile])
|
|
|
|
// Navigate to settings screen
|
|
const navigateToSettings = useCallback(() => {
|
|
navigation.navigate("Settings")
|
|
}, [navigation])
|
|
|
|
// Navigate to security screen
|
|
const navigateToSecurity = useCallback(() => {
|
|
navigation.navigate("Security")
|
|
}, [navigation])
|
|
|
|
// Navigate to about screen
|
|
const navigateToAbout = useCallback(() => {
|
|
navigation.navigate("About")
|
|
}, [navigation])
|
|
|
|
// 使用 useHeader hook 设置导航栏
|
|
useHeader(
|
|
{
|
|
title: translate("profileScreen:title"),
|
|
leftIcon: "back",
|
|
onLeftPress: () => navigation.goBack(),
|
|
rightIcon: "settings",
|
|
onRightPress: navigateToSettings,
|
|
},
|
|
[navigateToSettings],
|
|
)
|
|
|
|
// Get display avatar - either newly uploaded or existing
|
|
const displayAvatar = uploadedAvatarUrl || user?.profile?.avatar
|
|
|
|
return (
|
|
<Screen
|
|
preset="scroll"
|
|
safeAreaEdges={["bottom"]}
|
|
contentContainerStyle={[$styles.container, themed($container)]}
|
|
>
|
|
{/* User Profile Header - Horizontal Layout */}
|
|
<TouchableOpacity
|
|
style={themed($profileHeader)}
|
|
onPress={openEditProfile}
|
|
activeOpacity={0.7}
|
|
>
|
|
{/* Left: Avatar */}
|
|
<Avatar
|
|
uri={user?.profile?.avatar}
|
|
fallback={user?.profile?.nickname || user?.username || "U"}
|
|
size={s(80)}
|
|
/>
|
|
|
|
{/* Middle: User Info */}
|
|
<View style={themed($userInfoContainer)}>
|
|
{/* Top: UID */}
|
|
<Text size="xs" style={themed($uidText)}>
|
|
UID: {user?.userId || "-"}
|
|
</Text>
|
|
|
|
{/* Middle: Name */}
|
|
<Text preset="bold" size="lg" style={themed($userName)}>
|
|
{user?.profile?.nickname || user?.username || translate("profileScreen:guest")}
|
|
</Text>
|
|
|
|
{/* Bottom: Account Status Badge */}
|
|
<View
|
|
style={[
|
|
$statusBadge,
|
|
{ backgroundColor: user?.status === "active" ? "#34C75920" : "#FF3B3020" },
|
|
]}
|
|
>
|
|
<Text
|
|
size="xxs"
|
|
style={[
|
|
$statusBadgeLabel,
|
|
{ color: user?.status === "active" ? "#34C759" : "#FF3B30" },
|
|
]}
|
|
>
|
|
{user?.status === "active"
|
|
? translate("profileScreen:regular")
|
|
: translate("profileScreen:inactive")}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Right: Edit Arrow */}
|
|
<Icon icon="caretRight" size={s(24)} color={theme.colors.textDim} />
|
|
</TouchableOpacity>
|
|
|
|
{/* Settings Section */}
|
|
<View style={themed($settingsSection)}>
|
|
<ListItem
|
|
tx="profileScreen:security"
|
|
leftIcon="lock"
|
|
leftIconColor={theme.colors.tint}
|
|
rightIcon="caretRight"
|
|
onPress={navigateToSecurity}
|
|
/>
|
|
|
|
<ListItem
|
|
tx="profileScreen:about"
|
|
leftIcon="info"
|
|
leftIconColor={theme.colors.tint}
|
|
RightComponent={
|
|
<View style={$rightContainer}>
|
|
<Text size="xs" style={themed($versionText)}>
|
|
v{Application.nativeApplicationVersion || "1.0.0"}
|
|
</Text>
|
|
<Icon icon="caretRight" size={s(24)} color={theme.colors.textDim} />
|
|
</View>
|
|
}
|
|
onPress={navigateToAbout}
|
|
/>
|
|
</View>
|
|
|
|
{/* Edit Profile Modal */}
|
|
<Modal
|
|
visible={isEditProfileVisible}
|
|
onClose={() => setIsEditProfileVisible(false)}
|
|
preset="bottom"
|
|
titleTx="profileScreen:editProfile"
|
|
FooterComponent={
|
|
<View style={$modalFooter}>
|
|
<Button
|
|
tx="common:save"
|
|
preset="reversed"
|
|
style={$fullWidthButton}
|
|
onPress={handleSaveProfile}
|
|
disabled={isLoading || isUploading || !nickname.trim() || !hasChanges}
|
|
loading={isLoading}
|
|
/>
|
|
<Button
|
|
tx="common:cancel"
|
|
preset="default"
|
|
style={$fullWidthButton}
|
|
onPress={() => setIsEditProfileVisible(false)}
|
|
disabled={isUploading || isLoading}
|
|
/>
|
|
</View>
|
|
}
|
|
>
|
|
<View style={themed($modalContent)}>
|
|
<Animated.View
|
|
pointerEvents="none"
|
|
style={[
|
|
themed($inlineToast),
|
|
{
|
|
opacity: copyToastAnim,
|
|
transform: [
|
|
{
|
|
translateY: copyToastAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [-8, 0],
|
|
}),
|
|
},
|
|
],
|
|
},
|
|
]}
|
|
>
|
|
<Text size="xs" text={translate("common:copied")} style={themed($inlineToastText)} />
|
|
</Animated.View>
|
|
|
|
{/* Avatar Picker */}
|
|
<TouchableOpacity
|
|
onPress={pickImage}
|
|
style={themed($avatarPickerContainer)}
|
|
activeOpacity={0.7}
|
|
disabled={isUploading}
|
|
>
|
|
<View>
|
|
<Avatar
|
|
uri={displayAvatar}
|
|
fallback={nickname || user?.username || "U"}
|
|
size={s(80)}
|
|
/>
|
|
{isUploading && (
|
|
<View style={themed($avatarLoadingOverlay)}>
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
</View>
|
|
)}
|
|
</View>
|
|
<View style={themed($avatarPickerBadge)}>
|
|
<Icon icon="camera" size={s(14)} color={theme.colors.palette.neutral100} />
|
|
</View>
|
|
</TouchableOpacity>
|
|
<Text size="xs" style={themed($avatarHint)}>
|
|
{translate("profileScreen:tapToChangeAvatar")}
|
|
</Text>
|
|
|
|
{/* UID (Read-only with copy) */}
|
|
<TouchableOpacity
|
|
style={themed($readOnlyField)}
|
|
onPress={() => user?.userId && copyToClipboard(String(user.userId))}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text size="xs" style={themed($readOnlyLabel)}>
|
|
{translate("profileScreen:uid")}
|
|
</Text>
|
|
<View style={$readOnlyRow}>
|
|
<Text style={themed($readOnlyValue)}>{user?.userId || "-"}</Text>
|
|
{user?.userId && (
|
|
<PressableIcon
|
|
icon="copy"
|
|
size={18}
|
|
color={theme.colors.textDim}
|
|
onPress={() => copyToClipboard(String(user.userId))}
|
|
/>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
|
|
{/* Username (Read-only) */}
|
|
<View style={themed($readOnlyField)}>
|
|
<Text size="xs" style={themed($readOnlyLabel)}>
|
|
{translate("profileScreen:username")}
|
|
</Text>
|
|
<Text style={themed($readOnlyValue)}>{user?.username || "-"}</Text>
|
|
</View>
|
|
|
|
{/* Referral Code (Read-only with copy) */}
|
|
<TouchableOpacity
|
|
style={themed($readOnlyField)}
|
|
onPress={() => user?.referralCode && copyToClipboard(user.referralCode)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text size="xs" style={themed($readOnlyLabel)}>
|
|
{translate("profileScreen:referralCode")}
|
|
</Text>
|
|
<View style={$readOnlyRow}>
|
|
<Text style={themed($readOnlyValue)}>{user?.referralCode || "-"}</Text>
|
|
{user?.referralCode && (
|
|
<PressableIcon
|
|
icon="copy"
|
|
size={18}
|
|
color={theme.colors.textDim}
|
|
onPress={() => copyToClipboard(user.referralCode!)}
|
|
/>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
|
|
{/* Nickname Input (Editable) */}
|
|
<TextField
|
|
value={nickname}
|
|
onChangeText={setNickname}
|
|
containerStyle={themed($inputContainer)}
|
|
placeholder={translate("profileScreen:nicknamePlaceholder")}
|
|
labelTx="profileScreen:nickname"
|
|
/>
|
|
|
|
{error ? (
|
|
<Text size="xs" style={themed($errorText)}>
|
|
{error}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</Modal>
|
|
</Screen>
|
|
)
|
|
}
|
|
|
|
const $container: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
paddingTop: spacing.lg,
|
|
paddingBottom: spacing.xxl,
|
|
})
|
|
|
|
const $profileHeader: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
marginBottom: spacing.md,
|
|
})
|
|
|
|
const $userInfoContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
flex: 1,
|
|
marginLeft: spacing.md,
|
|
justifyContent: "center",
|
|
})
|
|
|
|
const $uidText: ThemedStyle<TextStyle> = ({ colors }) => ({
|
|
color: colors.textDim,
|
|
marginBottom: s(2),
|
|
})
|
|
|
|
const $statusBadge: ViewStyle = {
|
|
paddingHorizontal: s(8),
|
|
paddingVertical: s(3),
|
|
borderRadius: s(4),
|
|
marginTop: s(6),
|
|
alignSelf: "flex-start",
|
|
}
|
|
|
|
const $statusBadgeLabel: TextStyle = {
|
|
fontWeight: "600",
|
|
}
|
|
|
|
const $userName: ThemedStyle<TextStyle> = () => ({
|
|
// Name in horizontal layout - no margin needed
|
|
})
|
|
|
|
const $settingsSection: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
marginTop: spacing.lg,
|
|
})
|
|
|
|
const $avatarPickerContainer: ThemedStyle<ViewStyle> = () => ({
|
|
alignSelf: "center",
|
|
marginBottom: s(8),
|
|
})
|
|
|
|
const $avatarLoadingOverlay: ThemedStyle<ViewStyle> = () => ({
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
borderRadius: s(40),
|
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
})
|
|
|
|
const $avatarPickerBadge: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
|
position: "absolute",
|
|
bottom: 0,
|
|
right: 0,
|
|
width: s(28),
|
|
height: s(28),
|
|
borderRadius: s(14),
|
|
backgroundColor: colors.tint,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
})
|
|
|
|
const $modalFooter: ViewStyle = {
|
|
width: "100%",
|
|
gap: s(12),
|
|
}
|
|
|
|
const $fullWidthButton: ViewStyle = {
|
|
width: "100%",
|
|
}
|
|
|
|
const $avatarHint: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
|
color: colors.textDim,
|
|
textAlign: "center",
|
|
marginBottom: spacing.md,
|
|
})
|
|
|
|
const $modalContent: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
position: "relative",
|
|
paddingTop: spacing.xl,
|
|
})
|
|
|
|
const $inlineToast: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
|
position: "absolute",
|
|
top: 0,
|
|
alignSelf: "center",
|
|
backgroundColor: colors.palette.primary500,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: s(8),
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: s(2) },
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: s(4),
|
|
elevation: 5,
|
|
zIndex: 1,
|
|
})
|
|
|
|
const $inlineToastText: ThemedStyle<TextStyle> = ({ colors }) => ({
|
|
color: colors.palette.neutral100,
|
|
textAlign: "center",
|
|
})
|
|
|
|
const $readOnlyField: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
marginBottom: spacing.md,
|
|
})
|
|
|
|
const $readOnlyRow: ViewStyle = {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
}
|
|
|
|
const $readOnlyLabel: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
|
color: colors.textDim,
|
|
marginBottom: spacing.xxs,
|
|
})
|
|
|
|
const $readOnlyValue: ThemedStyle<TextStyle> = ({ colors }) => ({
|
|
color: colors.text,
|
|
})
|
|
|
|
const $inputContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
marginBottom: spacing.sm,
|
|
})
|
|
|
|
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
|
color: colors.error,
|
|
marginBottom: spacing.sm,
|
|
})
|
|
|
|
const $rightContainer: ViewStyle = {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
}
|
|
|
|
const $versionText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
|
color: colors.textDim,
|
|
marginRight: spacing.xs,
|
|
})
|