template_0205

This commit is contained in:
Sofio
2026-02-05 13:16:05 +08:00
commit d93e4d9c9f
197 changed files with 52810 additions and 0 deletions

View File

@@ -0,0 +1,556 @@
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,
})