✨ feat: 完整应用重构 - 优化界面架构与用户体验
主要变更: - 重构应用界面:优化首页、登录、认证流程 - 新增用户档案模块:包含完整的档案管理组件 - 优化路由结构:重新组织页面布局和导航 - 改进API集成:新增活动、内容分类等API模块 - 删除冗余组件:清理不必要的文件和依赖 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
333
components/profile/profile-edit-modal.tsx
Normal file
333
components/profile/profile-edit-modal.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
type ImageSourcePropType,
|
||||
} from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { deriveInitials } from '@/utils/profile-data';
|
||||
|
||||
type ProfileEditModalProps = {
|
||||
visible: boolean;
|
||||
avatarSource?: ImageSourcePropType;
|
||||
initialName: string;
|
||||
onClose: () => void;
|
||||
onSave: (payload: { name: string; avatar?: ImageSourcePropType }) => void;
|
||||
};
|
||||
|
||||
export function ProfileEditModal({
|
||||
visible,
|
||||
avatarSource,
|
||||
initialName,
|
||||
onClose,
|
||||
onSave,
|
||||
}: ProfileEditModalProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = colorScheme === 'dark' ? palettes.dark : palettes.light;
|
||||
const [nameValue, setNameValue] = useState(initialName);
|
||||
const [avatarCandidate, setAvatarCandidate] = useState<ImageSourcePropType | undefined>(avatarSource);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
setNameValue(initialName);
|
||||
setAvatarCandidate(avatarSource);
|
||||
}, [visible, initialName, avatarSource]);
|
||||
|
||||
const trimmedName = nameValue.trim();
|
||||
const initialTrimmedName = initialName.trim();
|
||||
const hasNameChanged = trimmedName !== initialTrimmedName;
|
||||
const avatarKey = getSourceKey(avatarCandidate);
|
||||
const initialAvatarKey = getSourceKey(avatarSource);
|
||||
const hasAvatarChanged = avatarKey !== initialAvatarKey;
|
||||
const isSaveDisabled = trimmedName.length === 0 || (!hasNameChanged && !hasAvatarChanged);
|
||||
|
||||
const handlePickAvatar = useCallback(async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert('Permission Required', 'Allow photo library access to choose a new portrait.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.85,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
if (!asset.uri) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarCandidate({ uri: asset.uri });
|
||||
} catch (error) {
|
||||
console.error('Failed to pick avatar', error);
|
||||
Alert.alert('Selection Failed', 'We could not open your photo library. Please try again.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (trimmedName.length === 0) {
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
name: trimmedName,
|
||||
avatar: avatarCandidate,
|
||||
});
|
||||
}, [avatarCandidate, onSave, trimmedName]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose} accessibilityRole="button" />
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.avoider}
|
||||
>
|
||||
<View style={[styles.card, { backgroundColor: palette.surface, borderColor: palette.frame }]}>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close profile editor"
|
||||
onPress={onClose}
|
||||
style={[styles.closeButton, { backgroundColor: palette.buttonGhost, borderColor: palette.frame }]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="close" size={18} color={palette.muted} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.avatarSection}>
|
||||
<View
|
||||
style={[
|
||||
styles.avatarShell,
|
||||
{ backgroundColor: palette.avatarBackdrop, borderColor: palette.frame },
|
||||
]}
|
||||
>
|
||||
{avatarCandidate ? (
|
||||
<Image source={avatarCandidate} style={styles.avatarImage} resizeMode="cover" />
|
||||
) : (
|
||||
<ThemedText style={[styles.avatarInitials, { color: palette.primary }]}>
|
||||
{deriveInitials(trimmedName || initialName)}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Choose a new profile picture"
|
||||
onPress={handlePickAvatar}
|
||||
style={[styles.cameraButton, { backgroundColor: palette.accent }]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="photo-camera" size={18} color={palette.onAccent} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ThemedText style={[styles.modalTitle, { color: palette.primary }]}>
|
||||
Edit Profile
|
||||
</ThemedText>
|
||||
|
||||
<View style={[styles.fieldWrapper, { backgroundColor: palette.field, borderColor: palette.frame }]}>
|
||||
<TextInput
|
||||
value={nameValue}
|
||||
onChangeText={setNameValue}
|
||||
placeholder="Display name"
|
||||
placeholderTextColor={palette.placeholder}
|
||||
style={[styles.textInput, { color: palette.primary }]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSave}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save profile changes"
|
||||
onPress={handleSave}
|
||||
disabled={isSaveDisabled}
|
||||
style={[
|
||||
styles.saveButton,
|
||||
{
|
||||
backgroundColor: isSaveDisabled ? palette.buttonGhost : palette.accent,
|
||||
borderColor: isSaveDisabled ? palette.frame : 'transparent',
|
||||
borderWidth: isSaveDisabled ? StyleSheet.hairlineWidth : 0,
|
||||
},
|
||||
]}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.saveLabel,
|
||||
{ color: isSaveDisabled ? palette.muted : palette.onAccent },
|
||||
]}
|
||||
>
|
||||
Save
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const palettes = {
|
||||
dark: {
|
||||
surface: '#121216',
|
||||
frame: '#1D1E24',
|
||||
avatarBackdrop: '#1F2026',
|
||||
field: '#18181D',
|
||||
primary: '#F6F7FA',
|
||||
muted: '#8E9098',
|
||||
placeholder: '#5B5D66',
|
||||
accent: '#B7FF2F',
|
||||
onAccent: '#050505',
|
||||
buttonGhost: '#101014',
|
||||
},
|
||||
light: {
|
||||
surface: '#FFFFFF',
|
||||
frame: '#D8DCE7',
|
||||
avatarBackdrop: '#E2E5EF',
|
||||
field: '#F3F5FC',
|
||||
primary: '#0F1320',
|
||||
muted: '#5E6474',
|
||||
placeholder: '#8C92A3',
|
||||
accent: '#405CFF',
|
||||
onAccent: '#FFFFFF',
|
||||
buttonGhost: '#EBEEF6',
|
||||
},
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(5,5,5,0.68)',
|
||||
},
|
||||
avoider: {
|
||||
width: '100%',
|
||||
paddingHorizontal: 28,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 32,
|
||||
paddingBottom: 28,
|
||||
borderWidth: 1,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
},
|
||||
closeButton: {
|
||||
alignSelf: 'flex-end',
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
avatarSection: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
avatarShell: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 44,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
avatarInitials: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
},
|
||||
cameraButton: {
|
||||
position: 'absolute',
|
||||
right: 18,
|
||||
bottom: 6,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
fieldWrapper: {
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
textInput: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
saveButton: {
|
||||
borderRadius: 999,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
saveLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
function getSourceKey(source?: ImageSourcePropType): string | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
if (typeof source === 'number') {
|
||||
return String(source);
|
||||
}
|
||||
if (Array.isArray(source)) {
|
||||
return source.map(getSourceKey).join('|');
|
||||
}
|
||||
if ('uri' in source && source.uri) {
|
||||
return source.uri;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user