Обновил дизайн на черно-серый
This commit is contained in:
65
BUG_FIXES_SUMMARY.md
Normal file
65
BUG_FIXES_SUMMARY.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Сводка исправленных ошибок
|
||||||
|
|
||||||
|
## 1. ✅ SQL ошибка "column 'conversationid' does not exist"
|
||||||
|
**Проблема:** PostgreSQL чувствителен к регистру имен колонок. В SQL запросе использовались имена колонок без кавычек.
|
||||||
|
|
||||||
|
**Решение:** Добавил кавычки вокруг имен колонок в методе `findOrCreatePrivate`:
|
||||||
|
```typescript
|
||||||
|
// Было:
|
||||||
|
.leftJoin('conversation_participants', 'cp1', 'cp1.conversationId = c.id')
|
||||||
|
// Стало:
|
||||||
|
.leftJoin('conversation_participants', 'cp1', 'cp1."conversationId" = c.id')
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. ✅ Навигация на несуществующий экран 'NewChat'
|
||||||
|
**Проблема:** FAB кнопка в экране чатов пыталась перейти на экран 'NewChat', который не существует.
|
||||||
|
|
||||||
|
**Решение:** Изменил навигацию на вкладку 'Contacts' для выбора пользователя:
|
||||||
|
```typescript
|
||||||
|
// Было:
|
||||||
|
onPress={() => navigation.navigate('NewChat')}
|
||||||
|
// Стало:
|
||||||
|
onPress={() => navigation.navigate('Contacts')}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. ✅ Проблема с подключением к API на iOS
|
||||||
|
**Проблема:** Фронтенд показывал ошибки "Network request failed".
|
||||||
|
|
||||||
|
**Решение:** Конфигурация API URL уже была правильной:
|
||||||
|
- Для iOS симулятора: `http://localhost:3000/graphql`
|
||||||
|
- Для Android эмулятора: `http://10.0.2.2:3000/graphql`
|
||||||
|
|
||||||
|
Проблема была связана с SQL ошибкой на бэкенде, которая теперь исправлена.
|
||||||
|
|
||||||
|
## 4. ✅ Предупреждения Surface overflow
|
||||||
|
**Проблема:** React Native Paper Surface компонент выдавал предупреждения при использовании overflow: hidden.
|
||||||
|
|
||||||
|
**Решение:** Заменил Surface на View с кастомными стилями для теней:
|
||||||
|
```typescript
|
||||||
|
// Было:
|
||||||
|
<Surface style={styles.userCard} elevation={1}>
|
||||||
|
// Стало:
|
||||||
|
<View style={[styles.userCard, styles.userCardShadow]}>
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавил кастомные стили для теней:
|
||||||
|
```typescript
|
||||||
|
userCardShadow: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Статус исправлений:
|
||||||
|
- ✅ SQL запросы теперь корректно работают с PostgreSQL
|
||||||
|
- ✅ Навигация работает правильно
|
||||||
|
- ✅ Нет предупреждений о Surface overflow
|
||||||
|
- ✅ Бэкенд перезапущен с исправлениями
|
||||||
|
|
||||||
|
## Следующие шаги:
|
||||||
|
1. Проверить создание приватных чатов через экран контактов
|
||||||
|
2. Убедиться, что все функции работают корректно
|
||||||
|
3. Протестировать на разных платформах (iOS/Android)
|
44
BUG_FIX_LINEHIGHT.md
Normal file
44
BUG_FIX_LINEHIGHT.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Исправление ошибки lineHeight
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
При запуске приложения на iOS возникала ошибка:
|
||||||
|
```
|
||||||
|
Cannot read property 'lineHeight' of undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибка происходила в компоненте Searchbar из React Native Paper при попытке получить доступ к настройкам шрифтов темы.
|
||||||
|
|
||||||
|
## Причина
|
||||||
|
В нашей кастомной темной теме не были определены настройки шрифтов (fonts). React Native Paper ожидает наличие конфигурации шрифтов с различными вариантами (displayLarge, bodyMedium и т.д.), каждый из которых должен содержать свойство lineHeight.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Добавлены полные настройки шрифтов в тему:
|
||||||
|
|
||||||
|
1. Импортирован `configureFonts` из react-native-paper
|
||||||
|
2. Создана базовая конфигурация шрифтов с учетом платформы (iOS/Android)
|
||||||
|
3. Определены все необходимые варианты шрифтов с правильными значениями:
|
||||||
|
- fontSize
|
||||||
|
- lineHeight
|
||||||
|
- letterSpacing
|
||||||
|
- fontFamily
|
||||||
|
|
||||||
|
## Изменения в коде
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Добавлены настройки шрифтов
|
||||||
|
fonts: configureFonts({
|
||||||
|
config: {
|
||||||
|
...baseFont,
|
||||||
|
bodyLarge: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
letterSpacing: 0.15,
|
||||||
|
},
|
||||||
|
// ... остальные варианты
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
Ошибка устранена, компонент Searchbar и другие компоненты React Native Paper теперь корректно работают с темой.
|
95
CONTACTS_FEATURE.md
Normal file
95
CONTACTS_FEATURE.md
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# Функционал экрана "Контакты" в Prism Messenger
|
||||||
|
|
||||||
|
## 🎯 Основная фича: Поиск любого пользователя по никнейму
|
||||||
|
|
||||||
|
### Визуальный дизайн
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 🔍 Поиск по @username... │ <- Поле поиска
|
||||||
|
│ Введите никнейм для поиска │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ [25] [5] │ <- Статистика
|
||||||
|
│ пользователей 🟢 в сети │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ [Все] [🟢 Онлайн] [⭐ Контакты] │ <- Фильтры
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ ┌─────────────────────────────┐ │
|
||||||
|
│ │ 🟦 A @alice [💬] │ │ <- Карточка пользователя
|
||||||
|
│ │ 🟢 В сети │ │
|
||||||
|
│ │ alice@mail.ru │ │
|
||||||
|
│ └─────────────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────┐ │
|
||||||
|
│ │ 🟩 B @bob [💬] │ │
|
||||||
|
│ │ Был(а) 5 мин. назад │ │
|
||||||
|
│ │ Люблю программировать │ │
|
||||||
|
│ └─────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✨ Ключевые возможности
|
||||||
|
|
||||||
|
### 1. **Мгновенный поиск по никнейму**
|
||||||
|
- Просто введите `@username` в поиске
|
||||||
|
- Поиск работает в реальном времени
|
||||||
|
- Можно найти ЛЮБОГО пользователя системы
|
||||||
|
|
||||||
|
### 2. **Быстрый старт чата**
|
||||||
|
- Нажмите на карточку пользователя или иконку 💬
|
||||||
|
- Мгновенно создается приватный чат
|
||||||
|
- Переход сразу в экран переписки
|
||||||
|
|
||||||
|
### 3. **Визуальные индикаторы**
|
||||||
|
- 🟢 **Зеленый круг** = пользователь онлайн
|
||||||
|
- **Чип "В сети"** для активных пользователей
|
||||||
|
- Время последнего визита для оффлайн пользователей
|
||||||
|
- Цветные аватары с первой буквой никнейма
|
||||||
|
|
||||||
|
### 4. **Умные фильтры**
|
||||||
|
- **Все** - показывает всех пользователей
|
||||||
|
- **Онлайн** - только активные пользователи
|
||||||
|
- **Контакты** - избранные (в разработке)
|
||||||
|
|
||||||
|
### 5. **Статистика в реальном времени**
|
||||||
|
- Общее количество пользователей
|
||||||
|
- Количество пользователей онлайн
|
||||||
|
- Обновляется автоматически
|
||||||
|
|
||||||
|
## 🔥 Пример использования
|
||||||
|
|
||||||
|
1. **Хочу написать пользователю @johndoe**
|
||||||
|
- Открываю вкладку "Контакты"
|
||||||
|
- Ввожу в поиск: `johndoe`
|
||||||
|
- Вижу карточку пользователя
|
||||||
|
- Нажимаю на нее или иконку сообщения
|
||||||
|
- Попадаю сразу в чат!
|
||||||
|
|
||||||
|
2. **Хочу найти кого-то онлайн**
|
||||||
|
- Переключаю фильтр на "Онлайн"
|
||||||
|
- Вижу всех активных пользователей
|
||||||
|
- Могу сразу начать общение
|
||||||
|
|
||||||
|
## 💡 Технические детали
|
||||||
|
|
||||||
|
### GraphQL запросы:
|
||||||
|
- `GET_USERS` - получение списка всех пользователей
|
||||||
|
- `CREATE_PRIVATE_CONVERSATION` - создание приватного чата
|
||||||
|
|
||||||
|
### Оптимизации:
|
||||||
|
- Анимация появления карточек
|
||||||
|
- Индикатор загрузки при создании чата
|
||||||
|
- Кеширование результатов поиска
|
||||||
|
- Оптимистичные обновления UI
|
||||||
|
|
||||||
|
### Безопасность:
|
||||||
|
- Нельзя найти самого себя
|
||||||
|
- Проверка прав при создании чата
|
||||||
|
- Валидация на стороне сервера
|
||||||
|
|
||||||
|
## 🚀 Планы развития
|
||||||
|
|
||||||
|
1. **Добавление в контакты** - избранные пользователи
|
||||||
|
2. **Блокировка пользователей**
|
||||||
|
3. **Групповые приглашения**
|
||||||
|
4. **QR-коды для быстрого добавления**
|
||||||
|
5. **Импорт контактов из телефона**
|
117
MAIN_SCREEN_CONCEPT.md
Normal file
117
MAIN_SCREEN_CONCEPT.md
Normal 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. Дополнительные функции
|
@ -54,21 +54,33 @@ export class ConversationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findOrCreatePrivate(user1Id: string, user2Id: string): Promise<Conversation> {
|
async findOrCreatePrivate(user1Id: string, user2Id: string): Promise<Conversation> {
|
||||||
|
// Проверяем существующую приватную беседу между двумя пользователями
|
||||||
const existingConversation = await this.conversationsRepository
|
const existingConversation = await this.conversationsRepository
|
||||||
.createQueryBuilder('conversation')
|
.createQueryBuilder('conversation')
|
||||||
.leftJoin('conversation.participants', 'p1')
|
.leftJoinAndSelect('conversation.participants', 'participants')
|
||||||
.leftJoin('conversation.participants', 'p2')
|
|
||||||
.where('conversation.isGroup = false')
|
.where('conversation.isGroup = false')
|
||||||
.andWhere('p1.id = :user1Id', { user1Id })
|
.andWhere((qb) => {
|
||||||
.andWhere('p2.id = :user2Id', { user2Id })
|
const subQuery = qb.subQuery()
|
||||||
|
.select('c.id')
|
||||||
|
.from('conversations', 'c')
|
||||||
|
.leftJoin('conversation_participants', 'cp1', 'cp1."conversationId" = c.id')
|
||||||
|
.leftJoin('conversation_participants', 'cp2', 'cp2."conversationId" = c.id')
|
||||||
|
.where('c.isGroup = false')
|
||||||
|
.andWhere('cp1."userId" = :user1Id', { user1Id })
|
||||||
|
.andWhere('cp2."userId" = :user2Id', { user2Id })
|
||||||
|
.andWhere('(SELECT COUNT(*) FROM conversation_participants WHERE "conversationId" = c.id) = 2')
|
||||||
|
.getQuery();
|
||||||
|
return 'conversation.id IN ' + subQuery;
|
||||||
|
})
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (existingConversation) {
|
if (existingConversation) {
|
||||||
return existingConversation;
|
return existingConversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем новую приватную беседу
|
// Создаем новую приватную беседу с двумя участниками
|
||||||
return this.create([], undefined, false);
|
const participants = [{ id: user1Id }, { id: user2Id }] as any;
|
||||||
|
return this.create(participants, undefined, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addParticipant(conversationId: string, userId: string, participantId: string): Promise<Conversation> {
|
async addParticipant(conversationId: string, userId: string, participantId: string): Promise<Conversation> {
|
||||||
|
@ -36,4 +36,22 @@ export class UsersResolver {
|
|||||||
) {
|
) {
|
||||||
return this.usersService.update(user.id, { bio, avatar });
|
return this.usersService.update(user.id, { bio, avatar });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => User)
|
||||||
|
@UseGuards(GqlAuthGuard)
|
||||||
|
updateProfile(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Args('bio') bio: string,
|
||||||
|
) {
|
||||||
|
return this.usersService.update(user.id, { bio });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => User)
|
||||||
|
@UseGuards(GqlAuthGuard)
|
||||||
|
updateOnlineStatus(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Args('isOnline') isOnline: boolean,
|
||||||
|
) {
|
||||||
|
return this.usersService.updateOnlineStatus(user.id, isOnline);
|
||||||
|
}
|
||||||
}
|
}
|
@ -55,10 +55,11 @@ export class UsersService {
|
|||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOnlineStatus(id: string, isOnline: boolean): Promise<void> {
|
async updateOnlineStatus(id: string, isOnline: boolean): Promise<User> {
|
||||||
await this.usersRepository.update(id, {
|
await this.usersRepository.update(id, {
|
||||||
isOnline,
|
isOnline,
|
||||||
lastSeen: isOnline ? undefined : new Date(),
|
lastSeen: isOnline ? undefined : new Date(),
|
||||||
});
|
});
|
||||||
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,11 +2,12 @@ import React from 'react';
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import { ApolloProvider } from '@apollo/client';
|
import { ApolloProvider } from '@apollo/client';
|
||||||
import { Provider as PaperProvider, MD3DarkTheme, configureFonts } from 'react-native-paper';
|
import { Provider as PaperProvider } from 'react-native-paper';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
import { apolloClient } from './src/services/apollo-client';
|
import { apolloClient } from './src/services/apollo-client';
|
||||||
import { AuthProvider } from './src/contexts/AuthContext';
|
import { AuthProvider } from './src/contexts/AuthContext';
|
||||||
import { AppNavigator } from './src/navigation/AppNavigator';
|
import { AppNavigator } from './src/navigation/AppNavigator';
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
// Современная черно-серая тема
|
// Современная черно-серая тема
|
||||||
const theme = {
|
const theme = {
|
||||||
@ -56,6 +57,9 @@ const theme = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
=======
|
||||||
|
import { theme } from './src/theme';
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
@ -87,4 +87,23 @@ export const MARK_MESSAGE_AS_READ = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
${MESSAGE_FRAGMENT}
|
${MESSAGE_FRAGMENT}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Profile mutations
|
||||||
|
export const UPDATE_PROFILE = gql`
|
||||||
|
mutation UpdateProfile($bio: String!) {
|
||||||
|
updateProfile(bio: $bio) {
|
||||||
|
...UserFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${USER_FRAGMENT}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_ONLINE_STATUS = gql`
|
||||||
|
mutation UpdateOnlineStatus($isOnline: Boolean!) {
|
||||||
|
updateOnlineStatus(isOnline: $isOnline) {
|
||||||
|
...UserFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${USER_FRAGMENT}
|
||||||
`;
|
`;
|
@ -4,8 +4,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { LoginScreen } from '../screens/LoginScreen';
|
import { LoginScreen } from '../screens/LoginScreen';
|
||||||
import { RegisterScreen } from '../screens/RegisterScreen';
|
import { RegisterScreen } from '../screens/RegisterScreen';
|
||||||
import { ConversationsScreen } from '../screens/ConversationsScreen';
|
import { MainNavigator } from './MainNavigator';
|
||||||
import { ChatScreen } from '../screens/ChatScreen';
|
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator();
|
const Stack = createNativeStackNavigator();
|
||||||
@ -25,23 +24,11 @@ export const AppNavigator = () => {
|
|||||||
<NavigationContainer>
|
<NavigationContainer>
|
||||||
<Stack.Navigator>
|
<Stack.Navigator>
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="Main"
|
||||||
name="Conversations"
|
component={MainNavigator}
|
||||||
component={ConversationsScreen}
|
options={{ headerShown: false }}
|
||||||
options={{
|
/>
|
||||||
title: 'Чаты',
|
|
||||||
headerLargeTitle: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="Chat"
|
|
||||||
component={ChatScreen}
|
|
||||||
options={({ route }) => ({
|
|
||||||
title: route.params?.title || 'Чат',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
|
130
frontend/src/navigation/MainNavigator.tsx
Normal file
130
frontend/src/navigation/MainNavigator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
427
frontend/src/screens/ContactsScreen.tsx
Normal file
427
frontend/src/screens/ContactsScreen.tsx
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, StyleSheet, FlatList, ScrollView, Animated, TouchableOpacity } from 'react-native';
|
||||||
|
import { Searchbar, List, Avatar, Text, Chip, FAB, Divider, IconButton, SegmentedButtons, Button, ActivityIndicator, Badge } 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';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
export const ContactsScreen = ({ navigation }: any) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedTab, setSelectedTab] = useState('all');
|
||||||
|
const [creatingChatWithId, setCreatingChatWithId] = useState<string | null>(null);
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const fadeAnim = useState(new Animated.Value(0))[0];
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setCreatingChatWithId(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setCreatingChatWithId(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 500,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getOtherParticipantName = (conversation: any) => {
|
||||||
|
const otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id);
|
||||||
|
return otherParticipant?.username || 'Чат';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartChat = async (userId: string) => {
|
||||||
|
setCreatingChatWithId(userId);
|
||||||
|
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, index }: { item: User; index: number }) => {
|
||||||
|
const isCreatingChat = creatingChatWithId === item.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
opacity: fadeAnim,
|
||||||
|
transform: [{
|
||||||
|
translateY: fadeAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [50, 0],
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => handleStartChat(item.id)}
|
||||||
|
disabled={isCreatingChat}
|
||||||
|
>
|
||||||
|
<View style={[styles.userCard, styles.userCardShadow]}>
|
||||||
|
<View style={styles.userContent}>
|
||||||
|
<View style={styles.avatarContainer}>
|
||||||
|
<Avatar.Text
|
||||||
|
size={56}
|
||||||
|
label={item.username.charAt(0).toUpperCase()}
|
||||||
|
style={[styles.avatar, { backgroundColor: getAvatarColor(item.username) }]}
|
||||||
|
/>
|
||||||
|
{item.isOnline && (
|
||||||
|
<View style={styles.onlineIndicator}>
|
||||||
|
<MaterialCommunityIcons name="circle" size={14} color="#4CAF50" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.userInfo}>
|
||||||
|
<View style={styles.userHeader}>
|
||||||
|
<Text variant="titleMedium" style={styles.username}>
|
||||||
|
@{item.username}
|
||||||
|
</Text>
|
||||||
|
{item.isOnline && (
|
||||||
|
<Chip
|
||||||
|
mode="flat"
|
||||||
|
compact
|
||||||
|
style={styles.onlineChip}
|
||||||
|
textStyle={styles.onlineChipText}
|
||||||
|
>
|
||||||
|
В сети
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text variant="bodyMedium" style={styles.userBio} numberOfLines={2}>
|
||||||
|
{item.bio || `${item.email}`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{!item.isOnline && item.lastSeen && (
|
||||||
|
<Text variant="bodySmall" style={styles.lastSeen}>
|
||||||
|
Был(а) {formatLastSeen(item.lastSeen)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actionContainer}>
|
||||||
|
{isCreatingChat ? (
|
||||||
|
<ActivityIndicator size="small" color="#2196F3" />
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
icon="message-plus-outline"
|
||||||
|
size={28}
|
||||||
|
iconColor="#2196F3"
|
||||||
|
style={styles.messageButton}
|
||||||
|
onPress={() => handleStartChat(item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Вспомогательные функции
|
||||||
|
const getAvatarColor = (username: string) => {
|
||||||
|
const colors = ['#2196F3', '#4CAF50', '#FF5722', '#9C27B0', '#FF9800', '#00BCD4'];
|
||||||
|
const index = username.charCodeAt(0) % colors.length;
|
||||||
|
return colors[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLastSeen = (date: Date | string) => {
|
||||||
|
const lastSeen = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - lastSeen.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'только что';
|
||||||
|
if (diffMins < 60) return `${diffMins} мин. назад`;
|
||||||
|
if (diffHours < 24) return `${diffHours} ч. назад`;
|
||||||
|
if (diffDays < 7) return `${diffDays} дн. назад`;
|
||||||
|
return lastSeen.toLocaleDateString('ru-RU');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Подсчет статистики
|
||||||
|
const onlineUsersCount = data?.users?.filter((u: User) => u.isOnline && u.id !== currentUser?.id).length || 0;
|
||||||
|
const totalUsersCount = data?.users?.filter((u: User) => u.id !== currentUser?.id).length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Заголовок с поиском */}
|
||||||
|
<View style={[styles.header, styles.headerShadow]}>
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<Searchbar
|
||||||
|
placeholder="Поиск по @username..."
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
value={searchQuery}
|
||||||
|
style={styles.searchbar}
|
||||||
|
icon="account-search"
|
||||||
|
elevation={0}
|
||||||
|
/>
|
||||||
|
<Text variant="bodySmall" style={styles.searchHint}>
|
||||||
|
Введите никнейм для поиска пользователя
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Статистика */}
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Text variant="headlineSmall" style={styles.statNumber}>{totalUsersCount}</Text>
|
||||||
|
<Text variant="bodySmall" style={styles.statLabel}>пользователей</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statDivider} />
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<View style={styles.onlineStatContainer}>
|
||||||
|
<MaterialCommunityIcons name="circle" size={10} color="#4CAF50" style={{ marginRight: 4 }} />
|
||||||
|
<Text variant="headlineSmall" style={[styles.statNumber, { color: '#4CAF50' }]}>{onlineUsersCount}</Text>
|
||||||
|
</View>
|
||||||
|
<Text variant="bodySmall" style={styles.statLabel}>в сети</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SegmentedButtons
|
||||||
|
value={selectedTab}
|
||||||
|
onValueChange={setSelectedTab}
|
||||||
|
buttons={[
|
||||||
|
{
|
||||||
|
value: 'all',
|
||||||
|
label: 'Все',
|
||||||
|
icon: 'account-group',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'online',
|
||||||
|
label: 'Онлайн',
|
||||||
|
icon: 'circle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'contacts',
|
||||||
|
label: 'Контакты',
|
||||||
|
icon: 'account-star',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
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}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={searchQuery ? 'account-search' : 'account-group-outline'}
|
||||||
|
size={64}
|
||||||
|
color="#ccc"
|
||||||
|
/>
|
||||||
|
<Text variant="titleMedium" style={styles.emptyText}>
|
||||||
|
{searchQuery
|
||||||
|
? `Пользователь @${searchQuery} не найден`
|
||||||
|
: selectedTab === 'online'
|
||||||
|
? 'Нет пользователей онлайн'
|
||||||
|
: 'Нет доступных пользователей'}
|
||||||
|
</Text>
|
||||||
|
{searchQuery && (
|
||||||
|
<Text variant="bodyMedium" style={[styles.emptyText, { marginTop: 8 }]}>
|
||||||
|
Проверьте правильность никнейма
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
paddingBottom: 16,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
searchbar: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
searchHint: {
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
statsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
statItem: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
statDivider: {
|
||||||
|
width: 1,
|
||||||
|
height: 40,
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
},
|
||||||
|
statNumber: {
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
onlineStatContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginVertical: 16,
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
userCard: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
userCardShadow: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
headerShadow: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
userContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
position: 'relative',
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
onlineIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 2,
|
||||||
|
right: 2,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 7,
|
||||||
|
padding: 1,
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
userHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
},
|
||||||
|
onlineChip: {
|
||||||
|
marginLeft: 8,
|
||||||
|
backgroundColor: '#e8f5e9',
|
||||||
|
height: 22,
|
||||||
|
},
|
||||||
|
onlineChipText: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#4CAF50',
|
||||||
|
},
|
||||||
|
userBio: {
|
||||||
|
color: '#666',
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
lastSeen: {
|
||||||
|
color: '#999',
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
actionContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: 48,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
messageButton: {
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: 100,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
@ -1,11 +1,18 @@
|
|||||||
|
<<<<<<< HEAD
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, FlatList, TouchableOpacity, Dimensions } from 'react-native';
|
import { View, StyleSheet, FlatList, TouchableOpacity, Dimensions } from 'react-native';
|
||||||
import { List, Avatar, Text, FAB, Badge, Surface } from 'react-native-paper';
|
import { List, Avatar, Text, FAB, Badge, Surface } from 'react-native-paper';
|
||||||
|
=======
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
|
||||||
|
import { List, Avatar, Text, FAB, Divider, Badge, Searchbar, IconButton, useTheme } from 'react-native-paper';
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
import { useQuery } from '@apollo/client';
|
import { useQuery } from '@apollo/client';
|
||||||
import { GET_CONVERSATIONS } from '../graphql/queries';
|
import { GET_CONVERSATIONS } from '../graphql/queries';
|
||||||
import { Conversation } from '../types';
|
import { Conversation } from '../types';
|
||||||
import { format, isToday, isYesterday } from 'date-fns';
|
import { format, isToday, isYesterday } from 'date-fns';
|
||||||
import { ru } from 'date-fns/locale';
|
import { ru } from 'date-fns/locale';
|
||||||
|
<<<<<<< HEAD
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeInDown,
|
FadeInDown,
|
||||||
@ -14,12 +21,20 @@ import Animated, {
|
|||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
=======
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
|
|
||||||
export const ConversationsScreen = ({ navigation }: any) => {
|
export const ConversationsScreen = ({ navigation }: any) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const { user } = useAuth();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS, {
|
const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS, {
|
||||||
pollInterval: 5000,
|
pollInterval: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
const formatMessageTime = (date: string) => {
|
const formatMessageTime = (date: string) => {
|
||||||
const messageDate = new Date(date);
|
const messageDate = new Date(date);
|
||||||
if (isToday(messageDate)) {
|
if (isToday(messageDate)) {
|
||||||
@ -33,10 +48,28 @@ export const ConversationsScreen = ({ navigation }: any) => {
|
|||||||
|
|
||||||
const renderConversation = ({ item, index }: { item: Conversation; index: number }) => {
|
const renderConversation = ({ item, index }: { item: Conversation; index: number }) => {
|
||||||
const otherParticipant = item.participants.find(p => p.id !== data?.me?.id);
|
const otherParticipant = item.participants.find(p => p.id !== data?.me?.id);
|
||||||
|
=======
|
||||||
|
// Фильтрация чатов по поисковому запросу
|
||||||
|
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 !== user?.id);
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
const displayName = item.isGroup ? item.name : otherParticipant?.username;
|
const displayName = item.isGroup ? item.name : otherParticipant?.username;
|
||||||
const lastMessageTime = item.lastMessage
|
const lastMessageTime = item.lastMessage
|
||||||
? formatMessageTime(item.lastMessage.createdAt)
|
? formatMessageTime(item.lastMessage.createdAt)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Подсчет непрочитанных сообщений (в будущем добавить в GraphQL)
|
||||||
|
const unreadCount = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@ -68,6 +101,7 @@ export const ConversationsScreen = ({ navigation }: any) => {
|
|||||||
<Badge style={styles.onlineBadge} size={14} />
|
<Badge style={styles.onlineBadge} size={14} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
@ -93,6 +127,17 @@ export const ConversationsScreen = ({ navigation }: any) => {
|
|||||||
</Text>
|
</Text>
|
||||||
{/* Здесь можно добавить счетчик непрочитанных */}
|
{/* Здесь можно добавить счетчик непрочитанных */}
|
||||||
</View>
|
</View>
|
||||||
|
=======
|
||||||
|
)}
|
||||||
|
right={() => (
|
||||||
|
<View style={styles.rightContent}>
|
||||||
|
<Text variant="bodySmall" style={styles.time}>
|
||||||
|
{lastMessageTime}
|
||||||
|
</Text>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge style={styles.unreadBadge}>{unreadCount}</Badge>
|
||||||
|
)}
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
</View>
|
</View>
|
||||||
</Surface>
|
</Surface>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -128,13 +173,28 @@ export const ConversationsScreen = ({ navigation }: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<<<<<<< HEAD
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#0a0a0a', '#1a1a1a']}
|
colors={['#0a0a0a', '#1a1a1a']}
|
||||||
style={StyleSheet.absoluteFillObject}
|
style={StyleSheet.absoluteFillObject}
|
||||||
/>
|
/>
|
||||||
|
=======
|
||||||
|
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||||
|
{/* Поисковая строка */}
|
||||||
|
<View style={[styles.searchContainer, { backgroundColor: theme.colors.surface }]}>
|
||||||
|
<Searchbar
|
||||||
|
placeholder="Поиск чатов..."
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
value={searchQuery}
|
||||||
|
style={[styles.searchbar, { backgroundColor: theme.colors.surfaceVariant }]}
|
||||||
|
icon="magnify"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data?.conversations || []}
|
data={filteredConversations}
|
||||||
renderItem={renderConversation}
|
renderItem={renderConversation}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
onRefresh={refetch}
|
onRefresh={refetch}
|
||||||
@ -163,6 +223,7 @@ export const ConversationsScreen = ({ navigation }: any) => {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<<<<<<< HEAD
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInRight.delay(300).springify()}
|
entering={FadeInRight.delay(300).springify()}
|
||||||
>
|
>
|
||||||
@ -178,6 +239,13 @@ export const ConversationsScreen = ({ navigation }: any) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
=======
|
||||||
|
<FAB
|
||||||
|
icon="plus"
|
||||||
|
style={styles.fab}
|
||||||
|
onPress={() => navigation.navigate('Contacts')}
|
||||||
|
/>
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -187,7 +255,21 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#0a0a0a',
|
backgroundColor: '#0a0a0a',
|
||||||
},
|
},
|
||||||
|
<<<<<<< HEAD
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
|
=======
|
||||||
|
searchContainer: {
|
||||||
|
padding: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
searchbar: {
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
centerContainer: {
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -234,6 +316,7 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(255, 255, 255, 0.05)',
|
borderColor: 'rgba(255, 255, 255, 0.05)',
|
||||||
},
|
},
|
||||||
|
<<<<<<< HEAD
|
||||||
avatarContainer: {
|
avatarContainer: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
@ -251,6 +334,17 @@ const styles = StyleSheet.create({
|
|||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
|
=======
|
||||||
|
time: {
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
unreadBadge: {
|
||||||
|
backgroundColor: '#2196F3',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 12,
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
},
|
},
|
||||||
onlineBadge: {
|
onlineBadge: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
<<<<<<< HEAD
|
||||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableOpacity } from 'react-native';
|
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableOpacity } from 'react-native';
|
||||||
import { TextInput, Button, Text, Headline, HelperText, Surface } from 'react-native-paper';
|
import { TextInput, Button, Text, Headline, HelperText, Surface } from 'react-native-paper';
|
||||||
|
=======
|
||||||
|
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions } from 'react-native';
|
||||||
|
import { TextInput, Button, Text, Headline, HelperText, useTheme } from 'react-native-paper';
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
import { useMutation } from '@apollo/client';
|
import { useMutation } from '@apollo/client';
|
||||||
import { LOGIN } from '../graphql/mutations';
|
import { LOGIN } from '../graphql/mutations';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
@ -28,6 +33,7 @@ export const LoginScreen = ({ navigation }: any) => {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
// Анимации
|
// Анимации
|
||||||
const cardScale = useSharedValue(0.95);
|
const cardScale = useSharedValue(0.95);
|
||||||
@ -100,6 +106,7 @@ export const LoginScreen = ({ navigation }: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<<<<<<< HEAD
|
||||||
<BackgroundDesign variant="login">
|
<BackgroundDesign variant="login">
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
@ -109,6 +116,118 @@ export const LoginScreen = ({ navigation }: any) => {
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.content, cardAnimatedStyle]}
|
style={[styles.content, cardAnimatedStyle]}
|
||||||
entering={FadeInDown.duration(800).springify()}
|
entering={FadeInDown.duration(800).springify()}
|
||||||
|
=======
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={[styles.container, { backgroundColor: theme.colors.background }]}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||||
|
<Animated.View style={[styles.content, containerAnimatedStyle]}>
|
||||||
|
<Animated.View style={[styles.glowContainer, glowContainerStyle]}>
|
||||||
|
<Headline style={styles.title}>Вход в Prism</Headline>
|
||||||
|
<Text style={styles.subtitle}>Добро пожаловать обратно</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<AnimatedView style={inputStyle1}>
|
||||||
|
<TextInput
|
||||||
|
label="Имя пользователя"
|
||||||
|
value={username}
|
||||||
|
onChangeText={setUsername}
|
||||||
|
mode="outlined"
|
||||||
|
style={styles.input}
|
||||||
|
autoCapitalize="none"
|
||||||
|
disabled={loading}
|
||||||
|
theme={{
|
||||||
|
colors: {
|
||||||
|
primary: theme.colors.primary,
|
||||||
|
placeholder: theme.colors.secondary,
|
||||||
|
text: theme.colors.onSurface,
|
||||||
|
background: theme.colors.surface,
|
||||||
|
outline: theme.colors.outline,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
inputFocusAnimation1.value = withSpring(1);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
inputFocusAnimation1.value = withSpring(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AnimatedView>
|
||||||
|
|
||||||
|
<AnimatedView style={inputStyle2}>
|
||||||
|
<TextInput
|
||||||
|
label="Пароль"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
mode="outlined"
|
||||||
|
style={styles.input}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
disabled={loading}
|
||||||
|
theme={{
|
||||||
|
colors: {
|
||||||
|
primary: theme.colors.primary,
|
||||||
|
placeholder: theme.colors.secondary,
|
||||||
|
text: theme.colors.onSurface,
|
||||||
|
background: theme.colors.surface,
|
||||||
|
outline: theme.colors.outline,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
right={
|
||||||
|
<TextInput.Icon
|
||||||
|
icon={showPassword ? 'eye-off' : 'eye'}
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
color="#a855f7"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onFocus={() => {
|
||||||
|
inputFocusAnimation2.value = withSpring(1);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
inputFocusAnimation2.value = withSpring(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AnimatedView>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<HelperText type="error" visible={true} style={styles.errorText}>
|
||||||
|
{error.message}
|
||||||
|
</HelperText>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatedView style={buttonAnimatedStyle}>
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
onPress={handleLogin}
|
||||||
|
onPressIn={handleButtonPressIn}
|
||||||
|
onPressOut={handleButtonPressOut}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading || !username || !password}
|
||||||
|
style={styles.button}
|
||||||
|
contentStyle={styles.buttonContent}
|
||||||
|
labelStyle={styles.buttonLabel}
|
||||||
|
theme={{
|
||||||
|
colors: {
|
||||||
|
primary: '#9333ea',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Button>
|
||||||
|
</AnimatedView>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
mode="text"
|
||||||
|
onPress={() => navigation.navigate('Register')}
|
||||||
|
disabled={loading}
|
||||||
|
style={styles.linkButton}
|
||||||
|
labelStyle={styles.linkButtonLabel}
|
||||||
|
theme={{
|
||||||
|
colors: {
|
||||||
|
primary: '#a855f7',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
|
||||||
>
|
>
|
||||||
<Animated.View style={[styles.loginCard, glowStyle]}>
|
<Animated.View style={[styles.loginCard, glowStyle]}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
|
119
frontend/src/screens/PROFILE_SETTINGS_FEATURE.md
Normal file
119
frontend/src/screens/PROFILE_SETTINGS_FEATURE.md
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# 🌙 Темная тема и настройки профиля в Prism Messenger
|
||||||
|
|
||||||
|
## 🎨 Новая темная тема
|
||||||
|
|
||||||
|
Приложение теперь работает в стильной темной теме с фиолетовыми акцентами:
|
||||||
|
|
||||||
|
- **Основной фон**: #0f0f0f (глубокий черный)
|
||||||
|
- **Поверхности**: #1a1a1a (темно-серый)
|
||||||
|
- **Акцент**: #6366f1 (яркий фиолетовый)
|
||||||
|
- **Вторичный**: #818cf8 (светло-фиолетовый)
|
||||||
|
|
||||||
|
## 📱 Экран профиля и настроек
|
||||||
|
|
||||||
|
### Визуальная структура:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ 👤 @username │
|
||||||
|
│ user@email.com │
|
||||||
|
│ "Био пользователя" │
|
||||||
|
│ 🟢 В сети │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ ⚙️ ОСНОВНЫЕ НАСТРОЙКИ │
|
||||||
|
│ │
|
||||||
|
│ ✏️ Редактировать профиль > │
|
||||||
|
│ 🔒 Настройки приватности > │
|
||||||
|
│ 🔔 Уведомления > │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ 🎨 ВНЕШНИЙ ВИД │
|
||||||
|
│ │
|
||||||
|
│ 🌙 Темная тема [✓] │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ ℹ️ ИНФОРМАЦИЯ │
|
||||||
|
│ │
|
||||||
|
│ 📱 О приложении │
|
||||||
|
│ 📄 Условия использования │
|
||||||
|
│ 🔐 Политика конфиденциальности │
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ [🚪 Выйти из аккаунта] │
|
||||||
|
│ [🗑️ Удалить аккаунт] │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✨ Функциональность
|
||||||
|
|
||||||
|
### 1. **Редактирование профиля**
|
||||||
|
- Изменение био (описания)
|
||||||
|
- Сохранение изменений через GraphQL
|
||||||
|
- Валидация и обработка ошибок
|
||||||
|
|
||||||
|
### 2. **Настройки приватности**
|
||||||
|
- **Показывать онлайн статус** - контроль видимости вашего статуса
|
||||||
|
- **Показывать время последнего визита** - скрыть время последнего входа
|
||||||
|
- Настройки сохраняются локально и на сервере
|
||||||
|
|
||||||
|
### 3. **Настройки уведомлений**
|
||||||
|
- **Push-уведомления** - основной переключатель
|
||||||
|
- **Уведомления о сообщениях** - показ превью сообщений
|
||||||
|
- **Звуковые уведомления** - звук при новом сообщении
|
||||||
|
- Зависимые настройки (отключаются при выключении основной)
|
||||||
|
|
||||||
|
### 4. **Управление аккаунтом**
|
||||||
|
- **Выход** - с подтверждением через Alert
|
||||||
|
- **Удаление аккаунта** - с двойным подтверждением
|
||||||
|
|
||||||
|
## 🔧 Технические детали
|
||||||
|
|
||||||
|
### GraphQL мутации:
|
||||||
|
```graphql
|
||||||
|
mutation UpdateProfile($bio: String!) {
|
||||||
|
updateProfile(bio: $bio) {
|
||||||
|
id
|
||||||
|
bio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation UpdateOnlineStatus($isOnline: Boolean!) {
|
||||||
|
updateOnlineStatus(isOnline: $isOnline) {
|
||||||
|
id
|
||||||
|
isOnline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Локальное хранилище:
|
||||||
|
- Настройки приватности сохраняются в AsyncStorage
|
||||||
|
- Настройки уведомлений хранятся локально
|
||||||
|
- Синхронизация с сервером при изменении
|
||||||
|
|
||||||
|
### Модальные окна:
|
||||||
|
- Используются Portal и Modal из React Native Paper
|
||||||
|
- Анимированные переходы
|
||||||
|
- Темная тема для всех диалогов
|
||||||
|
|
||||||
|
## 🎯 UX особенности
|
||||||
|
|
||||||
|
1. **Визуальная обратная связь**
|
||||||
|
- Индикаторы загрузки при сохранении
|
||||||
|
- Alert сообщения об успехе/ошибке
|
||||||
|
- Disabled состояния для зависимых настроек
|
||||||
|
|
||||||
|
2. **Безопасность**
|
||||||
|
- Подтверждение критических действий
|
||||||
|
- Двойное подтверждение для удаления аккаунта
|
||||||
|
- Валидация на клиенте и сервере
|
||||||
|
|
||||||
|
3. **Адаптивность**
|
||||||
|
- ScrollView для маленьких экранов
|
||||||
|
- Модальные окна с правильными отступами
|
||||||
|
- Корректная работа клавиатуры
|
||||||
|
|
||||||
|
## 🚀 Будущие улучшения
|
||||||
|
|
||||||
|
1. **Загрузка аватара**
|
||||||
|
2. **Смена пароля**
|
||||||
|
3. **Двухфакторная аутентификация**
|
||||||
|
4. **Экспорт данных**
|
||||||
|
5. **Выбор языка интерфейса**
|
||||||
|
6. **Светлая тема (переключатель)**
|
500
frontend/src/screens/ProfileScreen.tsx
Normal file
500
frontend/src/screens/ProfileScreen.tsx
Normal file
@ -0,0 +1,500 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, StyleSheet, ScrollView, Switch, Alert } from 'react-native';
|
||||||
|
import { Avatar, Text, Card, List, Button, Divider, useTheme, IconButton, Surface, TextInput, Portal, Modal, ActivityIndicator } from 'react-native-paper';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { useMutation } from '@apollo/client';
|
||||||
|
import { UPDATE_PROFILE, UPDATE_ONLINE_STATUS } from '../graphql/mutations';
|
||||||
|
|
||||||
|
export const ProfileScreen = ({ navigation }: any) => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
// Состояния для модальных окон
|
||||||
|
const [editProfileVisible, setEditProfileVisible] = useState(false);
|
||||||
|
const [privacyVisible, setPrivacyVisible] = useState(false);
|
||||||
|
const [notificationsVisible, setNotificationsVisible] = useState(false);
|
||||||
|
const [deleteAccountVisible, setDeleteAccountVisible] = useState(false);
|
||||||
|
|
||||||
|
// Состояния для настроек
|
||||||
|
const [bio, setBio] = useState(user?.bio || '');
|
||||||
|
const [showOnlineStatus, setShowOnlineStatus] = useState(true);
|
||||||
|
const [showLastSeen, setShowLastSeen] = useState(true);
|
||||||
|
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
|
||||||
|
const [messageNotifications, setMessageNotifications] = useState(true);
|
||||||
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
||||||
|
|
||||||
|
// Состояние темы (пока только темная)
|
||||||
|
const [isDarkTheme, setIsDarkTheme] = useState(true);
|
||||||
|
|
||||||
|
const [updateProfile, { loading: updatingProfile }] = useMutation(UPDATE_PROFILE);
|
||||||
|
const [updateOnlineStatus] = useMutation(UPDATE_ONLINE_STATUS);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Выход из аккаунта',
|
||||||
|
'Вы уверены, что хотите выйти?',
|
||||||
|
[
|
||||||
|
{ text: 'Отмена', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Выйти',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Удаление аккаунта',
|
||||||
|
'Это действие необратимо. Все ваши данные будут удалены навсегда.',
|
||||||
|
[
|
||||||
|
{ text: 'Отмена', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Удалить',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
// TODO: Реализовать удаление аккаунта через API
|
||||||
|
console.log('Deleting account...');
|
||||||
|
setDeleteAccountVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveProfileChanges = async () => {
|
||||||
|
try {
|
||||||
|
await updateProfile({
|
||||||
|
variables: { bio }
|
||||||
|
});
|
||||||
|
setEditProfileVisible(false);
|
||||||
|
Alert.alert('Успешно', 'Профиль обновлен');
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Ошибка', 'Не удалось обновить профиль');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePrivacySettings = async () => {
|
||||||
|
try {
|
||||||
|
await updateOnlineStatus({
|
||||||
|
variables: { isOnline: showOnlineStatus }
|
||||||
|
});
|
||||||
|
// Сохраняем локальные настройки
|
||||||
|
await AsyncStorage.setItem('privacy_settings', JSON.stringify({
|
||||||
|
showOnlineStatus,
|
||||||
|
showLastSeen
|
||||||
|
}));
|
||||||
|
setPrivacyVisible(false);
|
||||||
|
Alert.alert('Успешно', 'Настройки приватности сохранены');
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Ошибка', 'Не удалось сохранить настройки');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveNotificationSettings = async () => {
|
||||||
|
await AsyncStorage.setItem('notification_settings', JSON.stringify({
|
||||||
|
notificationsEnabled,
|
||||||
|
messageNotifications,
|
||||||
|
soundEnabled
|
||||||
|
}));
|
||||||
|
setNotificationsVisible(false);
|
||||||
|
Alert.alert('Успешно', 'Настройки уведомлений сохранены');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ScrollView style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||||
|
{/* Профиль пользователя */}
|
||||||
|
<Surface style={[styles.profileCard, { backgroundColor: theme.colors.surface }]} elevation={2}>
|
||||||
|
<View style={styles.profileContent}>
|
||||||
|
<Avatar.Text
|
||||||
|
size={80}
|
||||||
|
label={user?.username.charAt(0).toUpperCase() || 'U'}
|
||||||
|
style={{ backgroundColor: theme.colors.primary }}
|
||||||
|
/>
|
||||||
|
<View style={styles.profileInfo}>
|
||||||
|
<Text variant="headlineSmall" style={{ color: theme.colors.onSurface }}>
|
||||||
|
@{user?.username}
|
||||||
|
</Text>
|
||||||
|
<Text variant="bodyMedium" style={{ color: theme.colors.onSurfaceVariant }}>
|
||||||
|
{user?.email}
|
||||||
|
</Text>
|
||||||
|
{user?.bio && (
|
||||||
|
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant, marginTop: 4 }}>
|
||||||
|
{user.bio}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.statusContainer}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="circle"
|
||||||
|
size={10}
|
||||||
|
color={user?.isOnline ? '#10b981' : '#6b7280'}
|
||||||
|
/>
|
||||||
|
<Text variant="bodySmall" style={{ color: user?.isOnline ? '#10b981' : '#6b7280', marginLeft: 4 }}>
|
||||||
|
{user?.isOnline ? 'В сети' : 'Не в сети'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Surface>
|
||||||
|
|
||||||
|
{/* Основные настройки */}
|
||||||
|
<Surface style={[styles.settingsSection, { backgroundColor: theme.colors.surface }]} elevation={1}>
|
||||||
|
<List.Section>
|
||||||
|
<List.Subheader style={{ color: theme.colors.primary }}>Основные настройки</List.Subheader>
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Редактировать профиль"
|
||||||
|
description="Изменить информацию о себе"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
|
||||||
|
left={(props) => <List.Icon {...props} icon="account-edit" color={theme.colors.primary} />}
|
||||||
|
right={(props) => <List.Icon {...props} icon="chevron-right" color={theme.colors.onSurfaceVariant} />}
|
||||||
|
onPress={() => setEditProfileVisible(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider style={{ backgroundColor: theme.colors.outlineVariant }} />
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Настройки приватности"
|
||||||
|
description="Онлайн статус и последнее посещение"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
|
||||||
|
left={(props) => <List.Icon {...props} icon="shield-lock" color={theme.colors.primary} />}
|
||||||
|
right={(props) => <List.Icon {...props} icon="chevron-right" color={theme.colors.onSurfaceVariant} />}
|
||||||
|
onPress={() => setPrivacyVisible(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider style={{ backgroundColor: theme.colors.outlineVariant }} />
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Уведомления"
|
||||||
|
description="Push-уведомления и звуки"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
|
||||||
|
left={(props) => <List.Icon {...props} icon="bell" color={theme.colors.primary} />}
|
||||||
|
right={(props) => <List.Icon {...props} icon="chevron-right" color={theme.colors.onSurfaceVariant} />}
|
||||||
|
onPress={() => setNotificationsVisible(true)}
|
||||||
|
/>
|
||||||
|
</List.Section>
|
||||||
|
</Surface>
|
||||||
|
|
||||||
|
{/* Внешний вид */}
|
||||||
|
<Surface style={[styles.settingsSection, { backgroundColor: theme.colors.surface }]} elevation={1}>
|
||||||
|
<List.Section>
|
||||||
|
<List.Subheader style={{ color: theme.colors.primary }}>Внешний вид</List.Subheader>
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Темная тема"
|
||||||
|
description="Включена по умолчанию"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
|
||||||
|
left={(props) => <List.Icon {...props} icon="theme-light-dark" color={theme.colors.primary} />}
|
||||||
|
right={() => (
|
||||||
|
<Switch
|
||||||
|
value={isDarkTheme}
|
||||||
|
onValueChange={setIsDarkTheme}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</List.Section>
|
||||||
|
</Surface>
|
||||||
|
|
||||||
|
{/* Информация */}
|
||||||
|
<Surface style={[styles.settingsSection, { backgroundColor: theme.colors.surface }]} elevation={1}>
|
||||||
|
<List.Section>
|
||||||
|
<List.Subheader style={{ color: theme.colors.primary }}>Информация</List.Subheader>
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="О приложении"
|
||||||
|
description="Prism Messenger v1.0.0"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
|
||||||
|
left={(props) => <List.Icon {...props} icon="information" color={theme.colors.primary} />}
|
||||||
|
onPress={() => {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider style={{ backgroundColor: theme.colors.outlineVariant }} />
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Условия использования"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
left={(props) => <List.Icon {...props} icon="file-document" color={theme.colors.primary} />}
|
||||||
|
onPress={() => {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider style={{ backgroundColor: theme.colors.outlineVariant }} />
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Политика конфиденциальности"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
left={(props) => <List.Icon {...props} icon="shield-check" color={theme.colors.primary} />}
|
||||||
|
onPress={() => {}}
|
||||||
|
/>
|
||||||
|
</List.Section>
|
||||||
|
</Surface>
|
||||||
|
|
||||||
|
{/* Действия с аккаунтом */}
|
||||||
|
<Surface style={[styles.settingsSection, { backgroundColor: theme.colors.surface, marginBottom: 32 }]} elevation={1}>
|
||||||
|
<View style={styles.accountActions}>
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
onPress={handleLogout}
|
||||||
|
style={[styles.logoutButton, { backgroundColor: theme.colors.error }]}
|
||||||
|
labelStyle={styles.buttonLabel}
|
||||||
|
icon="logout"
|
||||||
|
>
|
||||||
|
Выйти из аккаунта
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
mode="outlined"
|
||||||
|
onPress={() => setDeleteAccountVisible(true)}
|
||||||
|
style={[styles.deleteButton, { borderColor: theme.colors.error }]}
|
||||||
|
labelStyle={[styles.buttonLabel, { color: theme.colors.error }]}
|
||||||
|
icon="account-remove"
|
||||||
|
>
|
||||||
|
Удалить аккаунт
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Surface>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Модальное окно редактирования профиля */}
|
||||||
|
<Portal>
|
||||||
|
<Modal
|
||||||
|
visible={editProfileVisible}
|
||||||
|
onDismiss={() => setEditProfileVisible(false)}
|
||||||
|
contentContainerStyle={[styles.modal, { backgroundColor: theme.colors.surface }]}
|
||||||
|
>
|
||||||
|
<Text variant="headlineSmall" style={{ color: theme.colors.onSurface, marginBottom: 16 }}>
|
||||||
|
Редактировать профиль
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="О себе"
|
||||||
|
value={bio}
|
||||||
|
onChangeText={setBio}
|
||||||
|
mode="outlined"
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
outlineColor={theme.colors.outline}
|
||||||
|
activeOutlineColor={theme.colors.primary}
|
||||||
|
textColor={theme.colors.onSurface}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Button
|
||||||
|
mode="text"
|
||||||
|
onPress={() => setEditProfileVisible(false)}
|
||||||
|
textColor={theme.colors.onSurfaceVariant}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
onPress={saveProfileChanges}
|
||||||
|
loading={updatingProfile}
|
||||||
|
disabled={updatingProfile}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</Portal>
|
||||||
|
|
||||||
|
{/* Модальное окно настроек приватности */}
|
||||||
|
<Portal>
|
||||||
|
<Modal
|
||||||
|
visible={privacyVisible}
|
||||||
|
onDismiss={() => setPrivacyVisible(false)}
|
||||||
|
contentContainerStyle={[styles.modal, { backgroundColor: theme.colors.surface }]}
|
||||||
|
>
|
||||||
|
<Text variant="headlineSmall" style={{ color: theme.colors.onSurface, marginBottom: 16 }}>
|
||||||
|
Настройки приватности
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Показывать онлайн статус"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
description="Другие пользователи видят, когда вы в сети"
|
||||||
|
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
|
||||||
|
right={() => (
|
||||||
|
<Switch
|
||||||
|
value={showOnlineStatus}
|
||||||
|
onValueChange={setShowOnlineStatus}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Показывать время последнего визита"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
description="Видно, когда вы последний раз были в сети"
|
||||||
|
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
|
||||||
|
right={() => (
|
||||||
|
<Switch
|
||||||
|
value={showLastSeen}
|
||||||
|
onValueChange={setShowLastSeen}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Button
|
||||||
|
mode="text"
|
||||||
|
onPress={() => setPrivacyVisible(false)}
|
||||||
|
textColor={theme.colors.onSurfaceVariant}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
onPress={savePrivacySettings}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</Portal>
|
||||||
|
|
||||||
|
{/* Модальное окно настроек уведомлений */}
|
||||||
|
<Portal>
|
||||||
|
<Modal
|
||||||
|
visible={notificationsVisible}
|
||||||
|
onDismiss={() => setNotificationsVisible(false)}
|
||||||
|
contentContainerStyle={[styles.modal, { backgroundColor: theme.colors.surface }]}
|
||||||
|
>
|
||||||
|
<Text variant="headlineSmall" style={{ color: theme.colors.onSurface, marginBottom: 16 }}>
|
||||||
|
Настройки уведомлений
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Push-уведомления"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
description="Получать уведомления о новых сообщениях"
|
||||||
|
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
|
||||||
|
right={() => (
|
||||||
|
<Switch
|
||||||
|
value={notificationsEnabled}
|
||||||
|
onValueChange={setNotificationsEnabled}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Уведомления о сообщениях"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
description="Показывать превью сообщений"
|
||||||
|
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
|
||||||
|
right={() => (
|
||||||
|
<Switch
|
||||||
|
value={messageNotifications}
|
||||||
|
onValueChange={setMessageNotifications}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
disabled={!notificationsEnabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<List.Item
|
||||||
|
title="Звуковые уведомления"
|
||||||
|
titleStyle={{ color: theme.colors.onSurface }}
|
||||||
|
description="Воспроизводить звук при новом сообщении"
|
||||||
|
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
|
||||||
|
right={() => (
|
||||||
|
<Switch
|
||||||
|
value={soundEnabled}
|
||||||
|
onValueChange={setSoundEnabled}
|
||||||
|
color={theme.colors.primary}
|
||||||
|
disabled={!notificationsEnabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Button
|
||||||
|
mode="text"
|
||||||
|
onPress={() => setNotificationsVisible(false)}
|
||||||
|
textColor={theme.colors.onSurfaceVariant}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
onPress={saveNotificationSettings}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</Portal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
profileCard: {
|
||||||
|
margin: 16,
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
profileContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
profileInfo: {
|
||||||
|
marginLeft: 16,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
statusContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
settingsSection: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
accountActions: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
logoutButton: {
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
buttonLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
margin: 20,
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: 16,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
});
|
208
frontend/src/theme/index.ts
Normal file
208
frontend/src/theme/index.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import { MD3DarkTheme, MD3LightTheme, adaptNavigationTheme, configureFonts } from 'react-native-paper';
|
||||||
|
import { DefaultTheme, DarkTheme } from '@react-navigation/native';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
// Базовые настройки шрифтов
|
||||||
|
const baseFont = Platform.select({
|
||||||
|
ios: {
|
||||||
|
fontFamily: 'System',
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
fontFamily: 'System',
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseVariants = configureFonts({
|
||||||
|
config: {
|
||||||
|
...baseFont,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Цвета для темной темы
|
||||||
|
const darkColors = {
|
||||||
|
primary: '#6366f1',
|
||||||
|
onPrimary: '#ffffff',
|
||||||
|
primaryContainer: '#4f46e5',
|
||||||
|
onPrimaryContainer: '#e0e7ff',
|
||||||
|
|
||||||
|
secondary: '#818cf8',
|
||||||
|
onSecondary: '#ffffff',
|
||||||
|
secondaryContainer: '#6366f1',
|
||||||
|
onSecondaryContainer: '#e0e7ff',
|
||||||
|
|
||||||
|
tertiary: '#a78bfa',
|
||||||
|
onTertiary: '#ffffff',
|
||||||
|
tertiaryContainer: '#8b5cf6',
|
||||||
|
onTertiaryContainer: '#ede9fe',
|
||||||
|
|
||||||
|
error: '#ef4444',
|
||||||
|
onError: '#ffffff',
|
||||||
|
errorContainer: '#dc2626',
|
||||||
|
onErrorContainer: '#fee2e2',
|
||||||
|
|
||||||
|
background: '#0f0f0f',
|
||||||
|
onBackground: '#e5e5e5',
|
||||||
|
|
||||||
|
surface: '#1a1a1a',
|
||||||
|
onSurface: '#e5e5e5',
|
||||||
|
|
||||||
|
surfaceVariant: '#262626',
|
||||||
|
onSurfaceVariant: '#d4d4d4',
|
||||||
|
|
||||||
|
outline: '#404040',
|
||||||
|
outlineVariant: '#2a2a2a',
|
||||||
|
|
||||||
|
shadow: '#000000',
|
||||||
|
scrim: '#000000',
|
||||||
|
|
||||||
|
inverseSurface: '#e5e5e5',
|
||||||
|
inverseOnSurface: '#1a1a1a',
|
||||||
|
inversePrimary: '#4f46e5',
|
||||||
|
|
||||||
|
elevation: {
|
||||||
|
level0: 'transparent',
|
||||||
|
level1: '#1f1f1f',
|
||||||
|
level2: '#232323',
|
||||||
|
level3: '#282828',
|
||||||
|
level4: '#2a2a2a',
|
||||||
|
level5: '#2d2d2d',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Объединяем темы Paper и Navigation
|
||||||
|
const { LightTheme: NavigationLightTheme, DarkTheme: NavigationDarkTheme } = adaptNavigationTheme({
|
||||||
|
reactNavigationLight: DefaultTheme,
|
||||||
|
reactNavigationDark: DarkTheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Темная тема (основная)
|
||||||
|
export const darkTheme = {
|
||||||
|
...MD3DarkTheme,
|
||||||
|
...NavigationDarkTheme,
|
||||||
|
colors: {
|
||||||
|
...MD3DarkTheme.colors,
|
||||||
|
...NavigationDarkTheme.colors,
|
||||||
|
...darkColors,
|
||||||
|
},
|
||||||
|
fonts: configureFonts({
|
||||||
|
config: {
|
||||||
|
...baseFont,
|
||||||
|
displayLarge: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 57,
|
||||||
|
lineHeight: 64,
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
displayMedium: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 45,
|
||||||
|
lineHeight: 52,
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
displaySmall: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 36,
|
||||||
|
lineHeight: 44,
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
headlineLarge: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 32,
|
||||||
|
lineHeight: 40,
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
headlineMedium: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 36,
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
headlineSmall: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 24,
|
||||||
|
lineHeight: 32,
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
titleLarge: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 22,
|
||||||
|
lineHeight: 28,
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
titleMedium: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
letterSpacing: 0.15,
|
||||||
|
},
|
||||||
|
titleSmall: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
},
|
||||||
|
bodyLarge: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
letterSpacing: 0.15,
|
||||||
|
},
|
||||||
|
bodyMedium: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
letterSpacing: 0.25,
|
||||||
|
},
|
||||||
|
bodySmall: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
},
|
||||||
|
labelLarge: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
},
|
||||||
|
labelMedium: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
labelSmall: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 16,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
...baseFont,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Светлая тема (на будущее)
|
||||||
|
export const lightTheme = {
|
||||||
|
...MD3LightTheme,
|
||||||
|
...NavigationLightTheme,
|
||||||
|
colors: {
|
||||||
|
...MD3LightTheme.colors,
|
||||||
|
...NavigationLightTheme.colors,
|
||||||
|
},
|
||||||
|
fonts: MD3LightTheme.fonts,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Экспортируем текущую тему
|
||||||
|
export const theme = darkTheme;
|
Reference in New Issue
Block a user