Implement main screen structure with tab navigation, contacts and profile screens

This commit is contained in:
Bivekich
2025-08-06 05:20:51 +03:00
parent 4a11a6952a
commit 8d7b3718ce
6 changed files with 733 additions and 24 deletions

117
MAIN_SCREEN_CONCEPT.md Normal file
View File

@ -0,0 +1,117 @@
# Концепция главного экрана Prism Messenger
## Структура приложения после авторизации
### 1. Bottom Tab Navigation (Нижняя навигация)
Основные разделы приложения:
1. **Чаты** (Chats) - главный раздел
2. **Контакты** (Contacts) - список пользователей
3. **Профиль** (Profile) - настройки и профиль
### 2. Экран "Чаты" (главный)
#### Header (Заголовок):
- **Логотип/Название** приложения
- **Поиск** (иконка лупы)
- **Новый чат** (иконка +)
#### Основной контент:
- **Список активных чатов** с превью последних сообщений
- **Индикаторы непрочитанных** сообщений
- **Время последнего сообщения**
- **Онлайн статус** собеседника
- **Swipe actions** для быстрых действий (архив, удаление)
#### Floating Action Button:
- Быстрое создание нового чата
### 3. Экран "Контакты"
#### Разделы:
- **Поиск пользователей** по username
- **Мои контакты** (добавленные пользователи)
- **Все пользователи** системы
- **Приглашения** (входящие/исходящие)
#### Функции:
- Добавить в контакты
- Начать чат
- Посмотреть профиль
- Заблокировать пользователя
### 4. Экран "Профиль"
#### Разделы:
- **Мой профиль**:
- Аватар (с возможностью изменения)
- Username
- Email
- Био/Статус
- Статус "В сети"
- **Настройки**:
- Уведомления
- Приватность
- Темная тема
- Язык приложения
- **Действия**:
- Выйти из аккаунта
- Удалить аккаунт
### 5. Дополнительные функции
#### Глобальный поиск:
- По сообщениям
- По пользователям
- По названиям чатов
#### Уведомления:
- Push-уведомления о новых сообщениях
- Индикаторы на иконках табов
#### Статусы пользователей:
- Онлайн/Оффлайн
- Последний визит
- "Печатает..."
## Технические детали реализации
### Навигация:
```typescript
// React Navigation структура
- Tab.Navigator
- Stack.Navigator (Чаты)
- ConversationsScreen
- ChatScreen
- NewChatScreen
- Stack.Navigator (Контакты)
- ContactsScreen
- UserProfileScreen
- Stack.Navigator (Профиль)
- ProfileScreen
- SettingsScreen
```
### GraphQL запросы для главного экрана:
1. `getConversations` - список чатов с последними сообщениями
2. `getUnreadCount` - количество непрочитанных
3. `getOnlineUsers` - список онлайн пользователей
4. `searchUsers` - поиск пользователей
### Компоненты UI:
- `TabBar` - кастомная нижняя навигация
- `ChatListItem` - элемент списка чатов
- `SearchBar` - универсальный поиск
- `UserAvatar` - аватар с индикатором онлайн
- `UnreadBadge` - бейдж непрочитанных
## Приоритеты разработки:
1. Базовая Tab навигация
2. Улучшенный список чатов
3. Экран контактов
4. Профиль и настройки
5. Поиск и фильтрация
6. Дополнительные функции

View File

@ -4,8 +4,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useAuth } from '../contexts/AuthContext';
import { LoginScreen } from '../screens/LoginScreen';
import { RegisterScreen } from '../screens/RegisterScreen';
import { ConversationsScreen } from '../screens/ConversationsScreen';
import { ChatScreen } from '../screens/ChatScreen';
import { MainNavigator } from './MainNavigator';
import { ActivityIndicator, View } from 'react-native';
const Stack = createNativeStackNavigator();
@ -25,23 +24,11 @@ export const AppNavigator = () => {
<NavigationContainer>
<Stack.Navigator>
{user ? (
<>
<Stack.Screen
name="Conversations"
component={ConversationsScreen}
options={{
title: 'Чаты',
headerLargeTitle: true,
}}
/>
<Stack.Screen
name="Chat"
component={ChatScreen}
options={({ route }) => ({
title: route.params?.title || 'Чат',
})}
/>
</>
<Stack.Screen
name="Main"
component={MainNavigator}
options={{ headerShown: false }}
/>
) : (
<>
<Stack.Screen

View File

@ -0,0 +1,130 @@
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useTheme } from 'react-native-paper';
// Импортируем существующие экраны
import { ConversationsScreen } from '../screens/ConversationsScreen';
import { ChatScreen } from '../screens/ChatScreen';
// Заглушки для новых экранов (создадим позже)
import { ContactsScreen } from '../screens/ContactsScreen';
import { ProfileScreen } from '../screens/ProfileScreen';
const Tab = createBottomTabNavigator();
const ChatsStack = createNativeStackNavigator();
const ContactsStack = createNativeStackNavigator();
const ProfileStack = createNativeStackNavigator();
// Стек навигации для чатов
function ChatsStackNavigator() {
return (
<ChatsStack.Navigator>
<ChatsStack.Screen
name="ConversationsList"
component={ConversationsScreen}
options={{
title: 'Чаты',
headerLargeTitle: true,
}}
/>
<ChatsStack.Screen
name="Chat"
component={ChatScreen}
options={({ route }) => ({
title: route.params?.title || 'Чат',
})}
/>
</ChatsStack.Navigator>
);
}
// Стек навигации для контактов
function ContactsStackNavigator() {
return (
<ContactsStack.Navigator>
<ContactsStack.Screen
name="ContactsList"
component={ContactsScreen}
options={{
title: 'Контакты',
headerLargeTitle: true,
}}
/>
</ContactsStack.Navigator>
);
}
// Стек навигации для профиля
function ProfileStackNavigator() {
return (
<ProfileStack.Navigator>
<ProfileStack.Screen
name="ProfileMain"
component={ProfileScreen}
options={{
title: 'Профиль',
headerLargeTitle: true,
}}
/>
</ProfileStack.Navigator>
);
}
export function MainNavigator() {
const theme = useTheme();
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName: string;
if (route.name === 'Chats') {
iconName = focused ? 'message' : 'message-outline';
} else if (route.name === 'Contacts') {
iconName = focused ? 'account-group' : 'account-group-outline';
} else if (route.name === 'Profile') {
iconName = focused ? 'account-circle' : 'account-circle-outline';
} else {
iconName = 'help';
}
return <MaterialCommunityIcons name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: theme.colors.primary,
tabBarInactiveTintColor: 'gray',
headerShown: false,
tabBarStyle: {
backgroundColor: theme.colors.surface,
borderTopColor: theme.colors.outlineVariant,
borderTopWidth: 1,
},
})}
>
<Tab.Screen
name="Chats"
component={ChatsStackNavigator}
options={{
tabBarLabel: 'Чаты',
tabBarBadge: undefined, // Здесь можно показывать количество непрочитанных
}}
/>
<Tab.Screen
name="Contacts"
component={ContactsStackNavigator}
options={{
tabBarLabel: 'Контакты',
}}
/>
<Tab.Screen
name="Profile"
component={ProfileStackNavigator}
options={{
tabBarLabel: 'Профиль',
}}
/>
</Tab.Navigator>
);
}

View File

@ -0,0 +1,186 @@
import React, { useState } from 'react';
import { View, StyleSheet, FlatList, ScrollView } from 'react-native';
import { Searchbar, List, Avatar, Text, Chip, FAB, Divider, IconButton, SegmentedButtons } from 'react-native-paper';
import { useQuery, useMutation } from '@apollo/client';
import { GET_USERS } from '../graphql/queries';
import { CREATE_PRIVATE_CONVERSATION } from '../graphql/mutations';
import { User } from '../types';
import { useAuth } from '../contexts/AuthContext';
export const ContactsScreen = ({ navigation }: any) => {
const [searchQuery, setSearchQuery] = useState('');
const [selectedTab, setSelectedTab] = useState('all');
const { user: currentUser } = useAuth();
const { data, loading, refetch } = useQuery(GET_USERS);
const [createConversation] = useMutation(CREATE_PRIVATE_CONVERSATION, {
onCompleted: (data) => {
navigation.navigate('Chats', {
screen: 'Chat',
params: {
conversationId: data.createPrivateConversation.id,
title: getOtherParticipantName(data.createPrivateConversation),
},
});
},
});
const getOtherParticipantName = (conversation: any) => {
const otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id);
return otherParticipant?.username || 'Чат';
};
const handleStartChat = async (userId: string) => {
try {
await createConversation({
variables: { recipientId: userId },
});
} catch (error) {
console.error('Error creating conversation:', error);
}
};
const filteredUsers = data?.users?.filter((user: User) => {
if (user.id === currentUser?.id) return false;
if (searchQuery) {
return user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.toLowerCase());
}
return true;
}) || [];
const renderUser = ({ item }: { item: User }) => (
<>
<List.Item
title={item.username}
description={item.bio || item.email}
left={() => (
<View style={styles.avatarContainer}>
<Avatar.Text
size={48}
label={item.username.charAt(0).toUpperCase()}
/>
{item.isOnline && (
<View style={styles.onlineIndicator} />
)}
</View>
)}
right={() => (
<View style={styles.actions}>
<IconButton
icon="message-outline"
size={24}
onPress={() => handleStartChat(item.id)}
/>
</View>
)}
style={styles.listItem}
/>
<Divider />
</>
);
return (
<View style={styles.container}>
<View style={styles.searchContainer}>
<Searchbar
placeholder="Поиск пользователей..."
onChangeText={setSearchQuery}
value={searchQuery}
style={styles.searchbar}
/>
</View>
<SegmentedButtons
value={selectedTab}
onValueChange={setSelectedTab}
buttons={[
{ value: 'all', label: 'Все пользователи' },
{ value: 'contacts', label: 'Мои контакты' },
{ value: 'online', label: 'Онлайн' },
]}
style={styles.tabs}
/>
<FlatList
data={filteredUsers.filter((user: User) => {
if (selectedTab === 'online') return user.isOnline;
// В будущем добавим логику для "Мои контакты"
return true;
})}
renderItem={renderUser}
keyExtractor={(item) => item.id}
onRefresh={refetch}
refreshing={loading}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text variant="bodyLarge" style={styles.emptyText}>
{searchQuery
? 'Пользователи не найдены'
: selectedTab === 'online'
? 'Нет пользователей онлайн'
: 'Нет пользователей'}
</Text>
</View>
}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
searchContainer: {
padding: 16,
paddingBottom: 8,
},
searchbar: {
elevation: 0,
backgroundColor: '#f5f5f5',
},
tabs: {
marginHorizontal: 16,
marginBottom: 8,
},
listContent: {
flexGrow: 1,
},
listItem: {
paddingVertical: 8,
paddingHorizontal: 16,
},
avatarContainer: {
position: 'relative',
},
onlineIndicator: {
position: 'absolute',
bottom: 0,
right: 0,
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: '#4CAF50',
borderWidth: 2,
borderColor: '#ffffff',
},
actions: {
flexDirection: 'row',
alignItems: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: 100,
},
emptyText: {
color: '#666',
},
});

View File

@ -1,23 +1,41 @@
import React from 'react';
import React, { useState } from 'react';
import { View, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
import { List, Avatar, Text, FAB, Divider, Badge } from 'react-native-paper';
import { List, Avatar, Text, FAB, Divider, Badge, Searchbar, IconButton } from 'react-native-paper';
import { useQuery } from '@apollo/client';
import { GET_CONVERSATIONS } from '../graphql/queries';
import { Conversation } from '../types';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import { useAuth } from '../contexts/AuthContext';
export const ConversationsScreen = ({ navigation }: any) => {
const [searchQuery, setSearchQuery] = useState('');
const { user } = useAuth();
const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS, {
pollInterval: 5000, // Обновляем каждые 5 секунд
});
// Фильтрация чатов по поисковому запросу
const filteredConversations = data?.conversations?.filter((conv: Conversation) => {
if (!searchQuery) return true;
const otherParticipant = conv.participants.find(p => p.id !== user?.id);
const displayName = conv.isGroup ? conv.name : otherParticipant?.username;
return displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
conv.lastMessage?.content.toLowerCase().includes(searchQuery.toLowerCase());
}) || [];
const renderConversation = ({ item }: { item: Conversation }) => {
const otherParticipant = item.participants.find(p => p.id !== data?.me?.id);
const otherParticipant = item.participants.find(p => p.id !== user?.id);
const displayName = item.isGroup ? item.name : otherParticipant?.username;
const lastMessageTime = item.lastMessage
? format(new Date(item.lastMessage.createdAt), 'HH:mm', { locale: ru })
: '';
// Подсчет непрочитанных сообщений (в будущем добавить в GraphQL)
const unreadCount = 0;
return (
<TouchableOpacity
@ -42,7 +60,9 @@ export const ConversationsScreen = ({ navigation }: any) => {
<Text variant="bodySmall" style={styles.time}>
{lastMessageTime}
</Text>
{/* Здесь можно добавить счетчик непрочитанных сообщений */}
{unreadCount > 0 && (
<Badge style={styles.unreadBadge}>{unreadCount}</Badge>
)}
</View>
)}
style={styles.listItem}
@ -70,8 +90,19 @@ export const ConversationsScreen = ({ navigation }: any) => {
return (
<View style={styles.container}>
{/* Поисковая строка */}
<View style={styles.searchContainer}>
<Searchbar
placeholder="Поиск чатов..."
onChangeText={setSearchQuery}
value={searchQuery}
style={styles.searchbar}
icon="magnify"
/>
</View>
<FlatList
data={data?.conversations || []}
data={filteredConversations}
renderItem={renderConversation}
keyExtractor={(item) => item.id}
onRefresh={refetch}
@ -102,6 +133,16 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#ffffff',
},
searchContainer: {
padding: 16,
paddingBottom: 8,
backgroundColor: '#ffffff',
elevation: 2,
},
searchbar: {
elevation: 0,
backgroundColor: '#f5f5f5',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
@ -119,6 +160,13 @@ const styles = StyleSheet.create({
},
time: {
color: '#666',
fontSize: 12,
marginBottom: 4,
},
unreadBadge: {
backgroundColor: '#2196F3',
color: '#ffffff',
fontSize: 12,
},
onlineBadge: {
position: 'absolute',

View File

@ -0,0 +1,241 @@
import React, { useState } from 'react';
import { View, StyleSheet, ScrollView, Alert } from 'react-native';
import {
Avatar,
Text,
List,
Switch,
Divider,
Button,
IconButton,
Surface,
useTheme,
TextInput,
Dialog,
Portal,
} from 'react-native-paper';
import { useAuth } from '../contexts/AuthContext';
import { useMutation } from '@apollo/client';
import { UPDATE_USER } from '../graphql/mutations';
export const ProfileScreen = ({ navigation }: any) => {
const theme = useTheme();
const { user, logout } = useAuth();
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
const [darkMode, setDarkMode] = useState(false);
const [editDialogVisible, setEditDialogVisible] = useState(false);
const [bio, setBio] = useState(user?.bio || '');
const [updateUser] = useMutation(UPDATE_USER, {
onCompleted: (data) => {
// В реальном приложении нужно обновить контекст пользователя
setEditDialogVisible(false);
Alert.alert('Успешно', 'Профиль обновлен');
},
});
const handleLogout = () => {
Alert.alert(
'Выход',
'Вы уверены, что хотите выйти?',
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Выйти',
style: 'destructive',
onPress: async () => {
await logout();
}
},
]
);
};
const handleUpdateProfile = async () => {
try {
await updateUser({
variables: { bio },
});
} catch (error) {
Alert.alert('Ошибка', 'Не удалось обновить профиль');
}
};
return (
<ScrollView style={styles.container}>
{/* Профиль пользователя */}
<Surface style={styles.profileSection} elevation={0}>
<View style={styles.profileHeader}>
<Avatar.Text
size={80}
label={user?.username?.charAt(0).toUpperCase() || '?'}
style={styles.avatar}
/>
<IconButton
icon="pencil"
size={20}
onPress={() => setEditDialogVisible(true)}
style={styles.editButton}
/>
</View>
<Text variant="headlineSmall" style={styles.username}>
{user?.username}
</Text>
<Text variant="bodyMedium" style={styles.email}>
{user?.email}
</Text>
{user?.bio && (
<Text variant="bodyMedium" style={styles.bio}>
{user.bio}
</Text>
)}
</Surface>
<Divider />
{/* Настройки */}
<List.Section>
<List.Subheader>Настройки</List.Subheader>
<List.Item
title="Уведомления"
description="Получать push-уведомления"
left={(props) => <List.Icon {...props} icon="bell" />}
right={() => (
<Switch
value={notificationsEnabled}
onValueChange={setNotificationsEnabled}
/>
)}
/>
<List.Item
title="Темная тема"
description="Переключить тему приложения"
left={(props) => <List.Icon {...props} icon="theme-light-dark" />}
right={() => (
<Switch
value={darkMode}
onValueChange={setDarkMode}
/>
)}
/>
<List.Item
title="Язык"
description="Русский"
left={(props) => <List.Icon {...props} icon="translate" />}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => {}}
/>
<List.Item
title="Приватность"
description="Настройки приватности"
left={(props) => <List.Icon {...props} icon="lock" />}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => {}}
/>
</List.Section>
<Divider />
{/* О приложении */}
<List.Section>
<List.Subheader>О приложении</List.Subheader>
<List.Item
title="Версия"
description="1.0.0"
left={(props) => <List.Icon {...props} icon="information" />}
/>
<List.Item
title="Помощь"
left={(props) => <List.Icon {...props} icon="help-circle" />}
right={(props) => <List.Icon {...props} icon="chevron-right" />}
onPress={() => {}}
/>
</List.Section>
{/* Действия */}
<View style={styles.actions}>
<Button
mode="outlined"
onPress={handleLogout}
style={styles.logoutButton}
textColor={theme.colors.error}
>
Выйти из аккаунта
</Button>
</View>
{/* Диалог редактирования профиля */}
<Portal>
<Dialog visible={editDialogVisible} onDismiss={() => setEditDialogVisible(false)}>
<Dialog.Title>Редактировать профиль</Dialog.Title>
<Dialog.Content>
<TextInput
label="О себе"
value={bio}
onChangeText={setBio}
multiline
numberOfLines={3}
mode="outlined"
/>
</Dialog.Content>
<Dialog.Actions>
<Button onPress={() => setEditDialogVisible(false)}>Отмена</Button>
<Button onPress={handleUpdateProfile}>Сохранить</Button>
</Dialog.Actions>
</Dialog>
</Portal>
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
profileSection: {
padding: 24,
backgroundColor: '#ffffff',
alignItems: 'center',
},
profileHeader: {
position: 'relative',
marginBottom: 16,
},
avatar: {
backgroundColor: '#2196F3',
},
editButton: {
position: 'absolute',
bottom: -8,
right: -8,
backgroundColor: '#ffffff',
elevation: 2,
},
username: {
marginBottom: 4,
},
email: {
color: '#666',
marginBottom: 8,
},
bio: {
textAlign: 'center',
marginTop: 8,
paddingHorizontal: 16,
},
actions: {
padding: 16,
marginTop: 16,
},
logoutButton: {
borderColor: '#f44336',
},
});