Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
480
src/screens/message/CreateGroupScreen.tsx
Normal file
480
src/screens/message/CreateGroupScreen.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* CreateGroupScreen 创建群聊界面
|
||||
* 允许用户创建新的群聊,设置群名称、描述,并选择初始成员
|
||||
* 支持响应式布局
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
TextInput,
|
||||
FlatList,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { uploadService } from '../../services/uploadService';
|
||||
import { Avatar, Text, Button, Loading } from '../../components/common';
|
||||
import { User } from '../../types';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const CreateGroupScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
|
||||
// 表单状态
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [groupDescription, setGroupDescription] = useState('');
|
||||
const [groupAvatar, setGroupAvatar] = useState<string | null>(null);
|
||||
const [selectedMembers, setSelectedMembers] = useState<User[]>([]);
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 提交状态
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
|
||||
// 邀请成员模态框状态
|
||||
const [inviteModalVisible, setInviteModalVisible] = useState(false);
|
||||
|
||||
// 移除已选成员
|
||||
const removeSelectedMember = (userId: string) => {
|
||||
const newSelectedIds = new Set(selectedMemberIds);
|
||||
newSelectedIds.delete(userId);
|
||||
setSelectedMemberIds(newSelectedIds);
|
||||
setSelectedMembers(prev => prev.filter(m => m.id !== userId));
|
||||
};
|
||||
|
||||
// 选择群头像
|
||||
const handleSelectAvatar = async () => {
|
||||
try {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('提示', '需要访问相册权限才能选择头像');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
const selectedAsset = result.assets[0];
|
||||
setUploadingAvatar(true);
|
||||
|
||||
try {
|
||||
const uploadResult = await uploadService.uploadGroupAvatar({
|
||||
uri: selectedAsset.uri,
|
||||
type: selectedAsset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult && uploadResult.url) {
|
||||
setGroupAvatar(uploadResult.url);
|
||||
} else {
|
||||
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传头像失败:', error);
|
||||
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择头像失败:', error);
|
||||
Alert.alert('错误', '选择头像时发生错误');
|
||||
}
|
||||
};
|
||||
|
||||
// 创建群组
|
||||
const handleCreateGroup = async () => {
|
||||
// 验证群名称
|
||||
if (!groupName.trim()) {
|
||||
Alert.alert('提示', '请输入群名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupName.length > 50) {
|
||||
Alert.alert('提示', '群名称最多50个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupDescription.length > 500) {
|
||||
Alert.alert('提示', '群描述最多500个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const memberIds = selectedMembers.map(m => m.id);
|
||||
const response = await groupService.createGroup({
|
||||
name: groupName.trim(),
|
||||
description: groupDescription.trim() || undefined,
|
||||
member_ids: memberIds.length > 0 ? memberIds : undefined,
|
||||
});
|
||||
|
||||
// 如果设置了头像,更新群头像
|
||||
if (groupAvatar && response.id) {
|
||||
try {
|
||||
await groupService.setGroupAvatar(response.id, groupAvatar);
|
||||
} catch (avatarError) {
|
||||
console.error('设置群头像失败:', avatarError);
|
||||
}
|
||||
}
|
||||
|
||||
Alert.alert('成功', '群组创建成功', [
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => navigation.goBack(),
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
console.error('创建群组失败:', error);
|
||||
Alert.alert('创建失败', error.message || '创建群组时发生错误,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染已选成员
|
||||
const renderSelectedMember = ({ item }: { item: User }) => (
|
||||
<View style={styles.selectedMemberItem}>
|
||||
<Avatar source={item.avatar} size={48} name={item.nickname} />
|
||||
<TouchableOpacity
|
||||
style={styles.removeMemberButton}
|
||||
onPress={() => removeSelectedMember(item.id)}
|
||||
>
|
||||
<View style={styles.removeIconContainer}>
|
||||
<MaterialCommunityIcons name="close" size={12} color={colors.background.paper} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text variant="caption" numberOfLines={1} style={styles.selectedMemberName}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const handleConfirmMembers = (users: User[]) => {
|
||||
setSelectedMembers(users);
|
||||
setSelectedMemberIds(new Set(users.map(user => user.id)));
|
||||
setInviteModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 群头像和名称区域 */}
|
||||
<View style={styles.headerSection}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<TouchableOpacity style={styles.avatarWrapper} onPress={handleSelectAvatar} disabled={uploadingAvatar}>
|
||||
{uploadingAvatar ? (
|
||||
<View style={[styles.avatarPlaceholder, { width: 80, height: 80 }]}>
|
||||
<Loading />
|
||||
</View>
|
||||
) : groupAvatar ? (
|
||||
<Image source={{ uri: groupAvatar }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<Avatar
|
||||
source={undefined}
|
||||
size={80}
|
||||
name={groupName || '群'}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.avatarBadge}>
|
||||
<MaterialCommunityIcons name="camera" size={14} color={colors.background.paper} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.nameInputContainer}>
|
||||
<Text variant="label" style={styles.inputLabel}>
|
||||
群名称 <Text color={colors.error.main}>*</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.nameInput}
|
||||
value={groupName}
|
||||
onChangeText={setGroupName}
|
||||
placeholder="请输入群名称"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={50}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.charCount}>
|
||||
{groupName.length}/50
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 群描述输入 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="label" style={styles.sectionTitle}>
|
||||
群描述
|
||||
</Text>
|
||||
<View style={styles.textAreaContainer}>
|
||||
<TextInput
|
||||
style={styles.textArea}
|
||||
value={groupDescription}
|
||||
onChangeText={setGroupDescription}
|
||||
placeholder="介绍一下这个群聊吧..."
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={500}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.textAreaCharCount}>
|
||||
{groupDescription.length}/500
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 已选成员 */}
|
||||
{selectedMembers.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="label" style={styles.sectionTitle}>
|
||||
已选成员
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
{selectedMembers.length}人
|
||||
</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={selectedMembers}
|
||||
renderItem={renderSelectedMember}
|
||||
keyExtractor={item => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.selectedMembersList}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 邀请成员按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.inviteButton}
|
||||
onPress={() => setInviteModalVisible(true)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.inviteIconContainer}>
|
||||
<MaterialCommunityIcons name="account-plus" size={24} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.inviteTextContainer}>
|
||||
<Text variant="body" style={styles.inviteTitle}>邀请成员</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
仅可从互关好友中选择成员加入群聊
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={24} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
|
||||
{/* 创建按钮 */}
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
title="创建群聊"
|
||||
onPress={handleCreateGroup}
|
||||
loading={submitting}
|
||||
disabled={!groupName.trim() || submitting}
|
||||
fullWidth
|
||||
size="lg"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<MutualFollowSelectorModal
|
||||
visible={inviteModalVisible}
|
||||
title="邀请成员"
|
||||
confirmText="完成"
|
||||
initialSelectedIds={Array.from(selectedMemberIds)}
|
||||
onClose={() => setInviteModalVisible(false)}
|
||||
onConfirm={handleConfirmMembers}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
// 头部区域样式
|
||||
headerSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
avatarContainer: {
|
||||
marginRight: spacing.lg,
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
avatarImage: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: 40,
|
||||
},
|
||||
avatarBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.primary.main,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
nameInputContainer: {
|
||||
flex: 1,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
inputLabel: {
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nameInput: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
fontSize: fontSizes.lg,
|
||||
color: colors.text.primary,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
charCount: {
|
||||
textAlign: 'right',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
// 区域样式
|
||||
section: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
// 文本域样式
|
||||
textAreaContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
padding: spacing.md,
|
||||
},
|
||||
textArea: {
|
||||
minHeight: 100,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 22,
|
||||
},
|
||||
textAreaCharCount: {
|
||||
textAlign: 'right',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
// 已选成员样式
|
||||
selectedMembersList: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
selectedMemberItem: {
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.lg,
|
||||
width: 64,
|
||||
},
|
||||
removeMemberButton: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: 4,
|
||||
},
|
||||
removeIconContainer: {
|
||||
backgroundColor: colors.text.secondary,
|
||||
borderRadius: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
selectedMemberName: {
|
||||
marginTop: spacing.xs,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
// 邀请按钮样式
|
||||
inviteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.xl,
|
||||
...shadows.sm,
|
||||
},
|
||||
inviteIconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
inviteTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
inviteTitle: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
// 底部按钮样式
|
||||
footer: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.divider,
|
||||
},
|
||||
});
|
||||
|
||||
export default CreateGroupScreen;
|
||||
Reference in New Issue
Block a user