template_0205
This commit is contained in:
556
RN_TEMPLATE/app/screens/ProfileScreen.tsx
Normal file
556
RN_TEMPLATE/app/screens/ProfileScreen.tsx
Normal 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,
|
||||
})
|
||||
Reference in New Issue
Block a user