diff --git a/MAIN_SCREEN_CONCEPT.md b/MAIN_SCREEN_CONCEPT.md new file mode 100644 index 0000000..4e4d31f --- /dev/null +++ b/MAIN_SCREEN_CONCEPT.md @@ -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. Дополнительные функции \ No newline at end of file diff --git a/frontend/src/navigation/AppNavigator.tsx b/frontend/src/navigation/AppNavigator.tsx index fd58792..7ad6743 100644 --- a/frontend/src/navigation/AppNavigator.tsx +++ b/frontend/src/navigation/AppNavigator.tsx @@ -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 = () => { {user ? ( - <> - - ({ - title: route.params?.title || 'Чат', - })} - /> - + ) : ( <> + + ({ + title: route.params?.title || 'Чат', + })} + /> + + ); +} + +// Стек навигации для контактов +function ContactsStackNavigator() { + return ( + + + + ); +} + +// Стек навигации для профиля +function ProfileStackNavigator() { + return ( + + + + ); +} + +export function MainNavigator() { + const theme = useTheme(); + + return ( + ({ + 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 ; + }, + tabBarActiveTintColor: theme.colors.primary, + tabBarInactiveTintColor: 'gray', + headerShown: false, + tabBarStyle: { + backgroundColor: theme.colors.surface, + borderTopColor: theme.colors.outlineVariant, + borderTopWidth: 1, + }, + })} + > + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/screens/ContactsScreen.tsx b/frontend/src/screens/ContactsScreen.tsx new file mode 100644 index 0000000..a8316c4 --- /dev/null +++ b/frontend/src/screens/ContactsScreen.tsx @@ -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 }) => ( + <> + ( + + + {item.isOnline && ( + + )} + + )} + right={() => ( + + handleStartChat(item.id)} + /> + + )} + style={styles.listItem} + /> + + + ); + + return ( + + + + + + + + { + if (selectedTab === 'online') return user.isOnline; + // В будущем добавим логику для "Мои контакты" + return true; + })} + renderItem={renderUser} + keyExtractor={(item) => item.id} + onRefresh={refetch} + refreshing={loading} + contentContainerStyle={styles.listContent} + ListEmptyComponent={ + + + {searchQuery + ? 'Пользователи не найдены' + : selectedTab === 'online' + ? 'Нет пользователей онлайн' + : 'Нет пользователей'} + + + } + /> + + ); +}; + +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', + }, +}); \ No newline at end of file diff --git a/frontend/src/screens/ConversationsScreen.tsx b/frontend/src/screens/ConversationsScreen.tsx index 733f721..3cab2ab 100644 --- a/frontend/src/screens/ConversationsScreen.tsx +++ b/frontend/src/screens/ConversationsScreen.tsx @@ -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 ( { {lastMessageTime} - {/* Здесь можно добавить счетчик непрочитанных сообщений */} + {unreadCount > 0 && ( + {unreadCount} + )} )} style={styles.listItem} @@ -70,8 +90,19 @@ export const ConversationsScreen = ({ navigation }: any) => { return ( + {/* Поисковая строка */} + + + + 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', diff --git a/frontend/src/screens/ProfileScreen.tsx b/frontend/src/screens/ProfileScreen.tsx new file mode 100644 index 0000000..204930e --- /dev/null +++ b/frontend/src/screens/ProfileScreen.tsx @@ -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 ( + + {/* Профиль пользователя */} + + + + setEditDialogVisible(true)} + style={styles.editButton} + /> + + + + {user?.username} + + + {user?.email} + + {user?.bio && ( + + {user.bio} + + )} + + + + + {/* Настройки */} + + Настройки + + } + right={() => ( + + )} + /> + + } + right={() => ( + + )} + /> + + } + right={(props) => } + onPress={() => {}} + /> + + } + right={(props) => } + onPress={() => {}} + /> + + + + + {/* О приложении */} + + О приложении + + } + /> + + } + right={(props) => } + onPress={() => {}} + /> + + + {/* Действия */} + + + + + {/* Диалог редактирования профиля */} + + setEditDialogVisible(false)}> + Редактировать профиль + + + + + + + + + + + ); +}; + +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', + }, +}); \ No newline at end of file