Files
frontend/src/screens/profile/EditProfileScreen.tsx
lan 4213d13b8f
Some checks failed
Frontend CI / ota-android (push) Successful in 1m30s
Frontend CI / build-and-push-web (push) Successful in 12m29s
Frontend CI / build-android-apk (push) Failing after 3h10m38s
refactor(ui): implement custom header system and unify screen layouts
Refactor the navigation and header strategy by replacing native stack headers with a custom `SimpleHeader` component across most profile and detail screens. This provides a more consistent UI/UX and better control over layout behavior.

- Implement `SimpleHeader` component in `src/components/common`.
- Disable native header rendering in `app/(app)/(tabs)/profile/_layout.tsx` and `app/_layout.tsx`.
- Update profile sub-screens to use `SimpleHeader` and `StatusBar` from `expo-status-bar`.
- Refactor `PostDetailScreen` to use a custom header implementation for better integration with the post author information.
- Update `UserScreen` to wrap content in `SafeAreaView` with the new header.
- Adjust `AppBackButton` to support transparent backgrounds.
2026-05-03 22:01:43 +08:00

621 lines
18 KiB
TypeScript

/**
* 编辑资料页 EditProfileScreen - Twitter/X 风格
* 威友 - 编辑用户资料
* 与 Twitter/X 个人资料编辑页样式一致
*/
import React, { useState, useMemo } from 'react';
import {
View,
ScrollView,
StyleSheet,
TouchableOpacity,
TextInput,
Alert,
KeyboardAvoidingView,
Platform,
Image,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import {
spacing,
fontSizes,
borderRadius,
useAppColors,
type AppColors,
} from '../../theme';
import { useAuthStore } from '../../stores';
import { Avatar, Text, SimpleHeader } from '../../components/common';
import { authService, uploadService } from '../../services';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
function createEditProfileStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
keyboardView: {
flex: 1,
},
scrollContent: {
paddingBottom: spacing.xl,
},
// ===== 封面区域 - Twitter 风格全宽 =====
coverContainer: {
position: 'relative',
backgroundColor: colors.background.default,
},
coverTouchable: {
width: '100%',
height: '100%',
},
coverImage: {
width: '100%',
height: '100%',
},
coverFallback: {
width: '100%',
height: '100%',
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
},
coverOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
},
// ===== 信息区 =====
infoSection: {
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
backgroundColor: colors.background.paper,
},
avatarActionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginBottom: spacing.sm,
},
avatarWrapper: {
// 负边距在 inline style 中计算
},
avatarContainer: {
borderRadius: borderRadius.full,
backgroundColor: colors.background.paper,
padding: 3,
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
},
avatarOverlay: {
position: 'absolute',
top: 3,
left: 3,
right: 3,
bottom: 3,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
justifyContent: 'center',
alignItems: 'center',
},
editAvatarButton: {
position: 'absolute',
bottom: 4,
right: 4,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background.paper,
},
// 名字预览
nameSection: {
marginBottom: spacing.sm,
},
nickname: {
fontSize: fontSizes['2xl'],
fontWeight: '800',
color: colors.text.primary,
marginBottom: 2,
},
username: {
fontSize: fontSizes.md,
color: colors.text.hint,
},
// 编辑提示
editHint: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
gap: spacing.xs,
},
editHintText: {
fontSize: fontSizes.xs,
color: colors.text.hint,
},
// ===== 表单区域 - Twitter 风格全宽卡片 =====
formSection: {
backgroundColor: colors.background.paper,
marginTop: spacing.md,
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
},
formField: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
formFieldLast: {
borderBottomWidth: 0,
},
fieldLabel: {
fontSize: fontSizes.sm,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: spacing.xs,
},
fieldInput: {
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.xs,
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
disabledInput: {
color: colors.text.disabled,
},
charCount: {
textAlign: 'right',
marginTop: spacing.xs,
},
// 保存按钮
buttonContainer: {
marginTop: spacing.lg,
paddingHorizontal: spacing.lg,
},
saveButton: {
height: 48,
backgroundColor: colors.text.primary,
borderRadius: borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
},
saveButtonDisabled: {
opacity: 0.5,
},
saveButtonText: {
color: colors.background.paper,
fontSize: fontSizes.md,
fontWeight: '700',
},
});
}
type EditProfileSheet = ReturnType<typeof createEditProfileStyles>;
interface FormFieldProps {
label: string;
value: string;
onChangeText: (text: string) => void;
placeholder: string;
maxLength?: number;
multiline?: boolean;
numberOfLines?: number;
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad' | 'url';
editable?: boolean;
sheet: EditProfileSheet;
colors: AppColors;
isLast?: boolean;
}
const FormField: React.FC<FormFieldProps> = ({
label,
value,
onChangeText,
placeholder,
maxLength,
multiline = false,
numberOfLines = 1,
autoCapitalize = 'sentences',
keyboardType = 'default',
editable = true,
sheet,
colors,
isLast = false,
}) => {
return (
<View style={[sheet.formField, isLast && sheet.formFieldLast]}>
<Text style={sheet.fieldLabel}>{label}</Text>
<TextInput
style={[
sheet.fieldInput,
multiline && sheet.textArea,
!editable && sheet.disabledInput
]}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.text.hint}
maxLength={maxLength}
multiline={multiline}
numberOfLines={numberOfLines}
autoCapitalize={autoCapitalize}
keyboardType={keyboardType}
editable={editable}
/>
{maxLength && (
<Text variant="caption" color={colors.text.hint} style={sheet.charCount}>
{value.length}/{maxLength}
</Text>
)}
</View>
);
};
export const EditProfileScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createEditProfileStyles(colors), [colors]);
const navigation = useNavigation();
const router = useRouter();
const { currentUser, updateUser } = useAuthStore();
const { isWideScreen, isMobile, width } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 0, sm: 0, md: 0, lg: 24, xl: 32 });
const insets = useSafeAreaInsets();
const [nickname, setNickname] = useState(currentUser?.nickname || '');
const [bio, setBio] = useState(currentUser?.bio || '');
const [location, setLocation] = useState(currentUser?.location || '');
const [website, setWebsite] = useState(currentUser?.website || '');
const [phone, setPhone] = useState(currentUser?.phone || '');
const [email, setEmail] = useState(currentUser?.email || '');
const [avatar, setAvatar] = useState(currentUser?.avatar || '');
const [coverUrl, setCoverUrl] = useState(currentUser?.cover_url || '');
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false);
const [saving, setSaving] = useState(false);
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.lg;
const coverHeight = isWideScreen ? Math.min((width * 10) / 16, 320) : (width * 10) / 16;
const avatarSize = isWideScreen ? 120 : 88;
const handlePickCover = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('权限不足', '需要访问相册权限来选择头图');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: true,
aspect: [16, 9],
quality: 0.9,
});
if (!result.canceled && result.assets[0]) {
const selectedImage = result.assets[0];
setCoverUrl(selectedImage.uri);
try {
setUploadingCover(true);
const uploadResult = await uploadService.uploadCover({
uri: selectedImage.uri,
name: selectedImage.fileName || 'cover.jpg',
type: selectedImage.mimeType || 'image/jpeg',
});
if (uploadResult) {
updateUser({ cover_url: uploadResult.url });
Alert.alert('成功', '头图已更新');
} else {
Alert.alert('错误', '头图上传失败,请重试');
}
} catch (error) {
console.error('上传头图失败:', error);
Alert.alert('错误', '头图上传失败,请重试');
} finally {
setUploadingCover(false);
}
}
};
const handlePickAvatar = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('权限不足', '需要访问相册权限来选择头像');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
const selectedImage = result.assets[0];
setAvatar(selectedImage.uri);
try {
setUploadingAvatar(true);
const uploadResult = await uploadService.uploadAvatar({
uri: selectedImage.uri,
name: selectedImage.fileName || 'avatar.jpg',
type: selectedImage.mimeType || 'image/jpeg',
});
if (uploadResult) {
updateUser({ avatar: uploadResult.url });
Alert.alert('成功', '头像已更新');
} else {
Alert.alert('错误', '头像上传失败,请重试');
}
} catch (error) {
console.error('上传头像失败:', error);
Alert.alert('错误', '头像上传失败,请重试');
} finally {
setUploadingAvatar(false);
}
}
};
const handleSave = async () => {
if (!nickname.trim()) {
Alert.alert('错误', '昵称不能为空');
return;
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
Alert.alert('错误', '请输入正确的邮箱地址');
return;
}
if (phone && !/^1[3-9]\d{9}$/.test(phone)) {
Alert.alert('错误', '请输入正确的手机号');
return;
}
setSaving(true);
try {
const updatedUser = await authService.updateUser({
nickname: nickname.trim(),
bio: bio.trim() || undefined,
location: location.trim() || undefined,
website: website.trim() || undefined,
phone: phone.trim() || undefined,
email: email.trim() || undefined,
});
if (updatedUser) {
updateUser({
nickname: nickname.trim(),
bio: bio.trim() || undefined,
location: location.trim() || undefined,
website: website.trim() || undefined,
phone: phone.trim() || undefined,
email: email.trim() || undefined,
});
Alert.alert('成功', '资料已更新', [
{ text: '确定', onPress: () => navigation.goBack() }
]);
} else {
Alert.alert('错误', '保存失败,请重试');
}
} catch (error) {
console.error('保存资料失败:', error);
Alert.alert('错误', '保存失败,请重试');
} finally {
setSaving(false);
}
};
const renderFormContent = () => (
<>
{/* ===== 封面区域 - Twitter 风格全宽 ===== */}
<View style={[styles.coverContainer, { height: coverHeight }]}>
<TouchableOpacity
style={styles.coverTouchable}
onPress={handlePickCover}
activeOpacity={0.8}
>
{coverUrl ? (
<Image
source={{ uri: coverUrl }}
style={styles.coverImage}
resizeMode="cover"
/>
) : (
<View style={styles.coverFallback}>
<MaterialCommunityIcons name="image-outline" size={48} color="rgba(255,255,255,0.4)" />
</View>
)}
{uploadingCover ? (
<View style={styles.coverOverlay}>
<MaterialCommunityIcons name="loading" size={32} color={colors.text.inverse} />
</View>
) : (
<View style={styles.coverOverlay}>
<MaterialCommunityIcons name="camera" size={28} color={colors.text.inverse} />
</View>
)}
</TouchableOpacity>
</View>
{/* ===== 用户信息区 - Twitter 风格左对齐 ===== */}
<View style={styles.infoSection}>
<View style={styles.avatarActionRow}>
<View style={[styles.avatarWrapper, { marginTop: -avatarSize / 2 }]}>
<TouchableOpacity
style={[styles.avatarContainer, { width: avatarSize, height: avatarSize }]}
onPress={handlePickAvatar}
activeOpacity={0.8}
>
<Avatar
source={avatar || null}
size={avatarSize - 6}
name={nickname}
/>
{uploadingAvatar && (
<View style={styles.avatarOverlay}>
<MaterialCommunityIcons name="loading" size={24} color={colors.text.inverse} />
</View>
)}
<View style={styles.editAvatarButton}>
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
</View>
</TouchableOpacity>
</View>
</View>
<View style={styles.nameSection}>
<Text style={styles.nickname}>{nickname || currentUser?.nickname || ''}</Text>
<Text style={styles.username}>@{currentUser?.username || ''}</Text>
</View>
<View style={styles.editHint}>
<MaterialCommunityIcons name="information-outline" size={12} color={colors.text.hint} />
<Text style={styles.editHintText}></Text>
</View>
</View>
{/* ===== 表单区域 - Twitter 风格全宽分组 ===== */}
<View style={styles.formSection}>
<FormField
label="昵称"
value={nickname}
onChangeText={setNickname}
placeholder="请输入昵称"
maxLength={20}
sheet={styles}
colors={colors}
/>
<FormField
label="个人简介"
value={bio}
onChangeText={setBio}
placeholder="介绍一下自己"
maxLength={100}
multiline
numberOfLines={3}
sheet={styles}
colors={colors}
/>
<FormField
label="地区"
value={location}
onChangeText={setLocation}
placeholder="你所在的城市"
maxLength={30}
sheet={styles}
colors={colors}
/>
<FormField
label="个人网站"
value={website}
onChangeText={setWebsite}
placeholder="https://example.com"
maxLength={100}
autoCapitalize="none"
keyboardType="url"
sheet={styles}
colors={colors}
isLast
/>
</View>
{/* 联系方式区域 */}
<View style={[styles.formSection, { marginTop: spacing.md }]}>
<FormField
label="手机号"
value={phone}
onChangeText={setPhone}
placeholder="请输入手机号"
maxLength={11}
keyboardType="phone-pad"
sheet={styles}
colors={colors}
/>
<FormField
label="邮箱"
value={email}
onChangeText={setEmail}
placeholder="请输入邮箱地址"
maxLength={100}
autoCapitalize="none"
keyboardType="email-address"
sheet={styles}
colors={colors}
isLast
/>
</View>
{/* 保存按钮 */}
<View style={[styles.buttonContainer, { marginBottom: scrollBottomInset }]}>
<TouchableOpacity
style={[
styles.saveButton,
(saving || uploadingAvatar || uploadingCover) && styles.saveButtonDisabled
]}
onPress={handleSave}
disabled={saving || uploadingAvatar || uploadingCover}
activeOpacity={0.8}
>
<Text style={styles.saveButtonText}>
{saving ? '保存中...' : '保存修改'}
</Text>
</TouchableOpacity>
</View>
</>
);
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar style="auto" />
<SimpleHeader title="编辑资料" onBack={() => router.back()} />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingHorizontal: responsivePadding }]}
showsVerticalScrollIndicator={false}
>
{renderFormContent()}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
export default EditProfileScreen;