Compare commits
16 Commits
6f309d5390
...
main
Author | SHA1 | Date | |
---|---|---|---|
bd731f1f57 | |||
5a3fdc01f8 | |||
1eea449a30 | |||
1441440247 | |||
3f2e8e96f5 | |||
6a141d25b7 | |||
020682854d | |||
a3ad9832ae | |||
ffccf3ea7c | |||
cdc94e2a95 | |||
07eda45235 | |||
b3166b1dfe | |||
592aa33f21 | |||
8d7b3718ce | |||
4a11a6952a | |||
d0550acff9 |
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 теперь корректно работают с темой.
|
92
CLAUDE.md
Normal file
92
CLAUDE.md
Normal file
@ -0,0 +1,92 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Prism is a real-time messenger built with Expo (React Native) frontend and NestJS backend using GraphQL and PostgreSQL. The project is structured as a monorepo with separate frontend and backend directories.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend (NestJS)
|
||||
```bash
|
||||
cd backend
|
||||
npm run start:dev # Development server with hot reload
|
||||
npm run build # Build for production
|
||||
npm run test # Run unit tests
|
||||
npm run test:e2e # Run end-to-end tests
|
||||
npm run lint # ESLint with auto-fix
|
||||
npm run format # Prettier formatting
|
||||
```
|
||||
|
||||
### Frontend (Expo/React Native)
|
||||
```bash
|
||||
cd frontend
|
||||
npx expo start # Start Expo development server
|
||||
npx expo start --ios # Start with iOS simulator
|
||||
npx expo start --android # Start with Android emulator
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Architecture
|
||||
- **Framework**: NestJS with TypeScript
|
||||
- **API**: GraphQL using code-first approach with auto-generated schema
|
||||
- **Database**: PostgreSQL with TypeORM (synchronize enabled in development)
|
||||
- **Authentication**: JWT with Passport strategy
|
||||
- **Real-time**: GraphQL Subscriptions for live updates
|
||||
- **Module Structure**:
|
||||
- `auth/` - JWT authentication and guards
|
||||
- `users/` - User management and profiles
|
||||
- `conversations/` - Chat/conversation management
|
||||
- `messages/` - Message handling and real-time updates
|
||||
|
||||
### Frontend Architecture
|
||||
- **Framework**: Expo with React Native and TypeScript
|
||||
- **State Management**: Apollo Client for GraphQL state management
|
||||
- **UI Framework**: React Native Paper with custom theming
|
||||
- **Navigation**: React Navigation with stack and tab navigators
|
||||
- **Storage**: AsyncStorage for token persistence
|
||||
- **Key Contexts**: AuthContext for authentication state
|
||||
|
||||
### Data Flow
|
||||
- Frontend communicates with backend exclusively through GraphQL
|
||||
- Real-time updates via GraphQL subscriptions
|
||||
- JWT tokens stored in AsyncStorage and included in GraphQL requests via authLink
|
||||
- Apollo Client handles caching with cache-and-network fetch policy
|
||||
|
||||
## Database Configuration
|
||||
|
||||
The project supports both remote and local PostgreSQL:
|
||||
- Remote database is the default (configured in backend .env)
|
||||
- Local database can be enabled by uncommenting postgres service in docker-compose.yml
|
||||
- TypeORM synchronize is enabled in development (automatic schema updates)
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Backend Environment Variables
|
||||
Required in `backend/.env`:
|
||||
```
|
||||
DATABASE_HOST=
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_USERNAME=
|
||||
DATABASE_PASSWORD=
|
||||
DATABASE_NAME=prism_messenger
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRATION=7d
|
||||
```
|
||||
|
||||
### Frontend API Configuration
|
||||
Update `frontend/src/config/api.ts` based on development environment:
|
||||
- iOS simulator: http://localhost:3000/graphql
|
||||
- Android emulator: http://10.0.2.2:3000/graphql
|
||||
- Physical device: http://YOUR_IP:3000/graphql
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
- GraphQL schema is auto-generated at `backend/src/schema.gql`
|
||||
- All GraphQL queries, mutations, and subscriptions are centralized in `frontend/src/graphql/`
|
||||
- Authentication uses JWT with bearer token authorization
|
||||
- Real-time features implemented via GraphQL subscriptions with graphql-ws
|
||||
- Frontend theme system supports both light and dark modes
|
||||
- Message features include send, edit, delete with real-time updates
|
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. Дополнительные функции
|
@ -12,7 +12,9 @@ async function bootstrap() {
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
});
|
||||
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
console.log(`Application is running on: http://localhost:${process.env.PORT ?? 3000}/graphql`);
|
||||
const port = process.env.PORT ?? 3000;
|
||||
await app.listen(port, '0.0.0.0');
|
||||
console.log(`Application is running on: http://0.0.0.0:${port}/graphql`);
|
||||
console.log(`For mobile devices use: http://192.168.31.210:${port}/graphql`);
|
||||
}
|
||||
bootstrap();
|
||||
|
@ -25,7 +25,7 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Неверный логин или пароль');
|
||||
}
|
||||
|
||||
const payload = { username: user.username, sub: user.id };
|
||||
const payload = { username: user.username || user.email, sub: user.id };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user,
|
||||
@ -34,7 +34,7 @@ export class AuthService {
|
||||
|
||||
async register(username: string, email: string, password: string) {
|
||||
const user = await this.usersService.create(username, email, password);
|
||||
const payload = { username: user.username, sub: user.id };
|
||||
const payload = { username: user.username || user.email, sub: user.id };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user,
|
||||
|
@ -54,21 +54,33 @@ export class ConversationsService {
|
||||
}
|
||||
|
||||
async findOrCreatePrivate(user1Id: string, user2Id: string): Promise<Conversation> {
|
||||
// Проверяем существующую приватную беседу между двумя пользователями
|
||||
const existingConversation = await this.conversationsRepository
|
||||
.createQueryBuilder('conversation')
|
||||
.leftJoin('conversation.participants', 'p1')
|
||||
.leftJoin('conversation.participants', 'p2')
|
||||
.leftJoinAndSelect('conversation.participants', 'participants')
|
||||
.where('conversation.isGroup = false')
|
||||
.andWhere('p1.id = :user1Id', { user1Id })
|
||||
.andWhere('p2.id = :user2Id', { user2Id })
|
||||
.andWhere((qb) => {
|
||||
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();
|
||||
|
||||
if (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> {
|
||||
|
@ -9,13 +9,13 @@ export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
@Column({ unique: true })
|
||||
username: string;
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ unique: true, nullable: true })
|
||||
username?: string;
|
||||
|
||||
@Field()
|
||||
@Column({ unique: true })
|
||||
email: string;
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ unique: true, nullable: true })
|
||||
email?: string;
|
||||
|
||||
@HideField()
|
||||
@Column()
|
||||
@ -29,9 +29,9 @@ export class User {
|
||||
@Column({ nullable: true })
|
||||
bio?: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ default: false })
|
||||
isOnline: boolean;
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@Column({ default: false, nullable: true })
|
||||
isOnline?: boolean;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
@Column({ nullable: true })
|
||||
|
@ -36,4 +36,22 @@ export class UsersResolver {
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
@ -12,9 +12,13 @@ export class UsersService {
|
||||
) {}
|
||||
|
||||
async create(username: string, email: string, password: string): Promise<User> {
|
||||
const existingUser = await this.usersRepository.findOne({
|
||||
where: [{ username }, { email }],
|
||||
});
|
||||
const whereConditions: Array<{ username?: string; email?: string }> = [];
|
||||
if (username) whereConditions.push({ username });
|
||||
if (email) whereConditions.push({ email });
|
||||
|
||||
const existingUser = whereConditions.length > 0 ? await this.usersRepository.findOne({
|
||||
where: whereConditions,
|
||||
}) : null;
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Пользователь с таким username или email уже существует');
|
||||
@ -43,10 +47,12 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
if (!username) return null;
|
||||
return this.usersRepository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
if (!email) return null;
|
||||
return this.usersRepository.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
@ -55,10 +61,11 @@ export class UsersService {
|
||||
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, {
|
||||
isOnline,
|
||||
lastSeen: isOnline ? undefined : new Date(),
|
||||
});
|
||||
return this.findOne(id);
|
||||
}
|
||||
}
|
@ -6,15 +6,16 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { apolloClient } from './src/services/apollo-client';
|
||||
import { AuthProvider } from './src/contexts/AuthContext';
|
||||
import { AppNavigator } from './src/navigation/AppNavigator';
|
||||
import { theme } from './src/theme';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<PaperProvider>
|
||||
<PaperProvider theme={theme}>
|
||||
<AuthProvider>
|
||||
<AppNavigator />
|
||||
<StatusBar style="auto" />
|
||||
<StatusBar style="light" />
|
||||
</AuthProvider>
|
||||
</PaperProvider>
|
||||
</ApolloProvider>
|
||||
|
497
frontend/package-lock.json
generated
497
frontend/package-lock.json
generated
@ -9,23 +9,29 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.13.9",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@expo/metro-runtime": "~5.0.4",
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-navigation/bottom-tabs": "^7.4.5",
|
||||
"@react-navigation/native": "^7.1.17",
|
||||
"@react-navigation/native-stack": "^7.3.24",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~53.0.20",
|
||||
"expo-linear-gradient": "~14.1.5",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-ws": "^6.0.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native": "0.79.5",
|
||||
"react-native-gesture-handler": "^2.27.2",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||
"react-native-paper": "^5.14.5",
|
||||
"react-native-reanimated": "^4.0.1",
|
||||
"react-native-safe-area-context": "^5.5.2",
|
||||
"react-native-screens": "^4.13.1",
|
||||
"react-native-vector-icons": "^10.3.0"
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-svg": "15.11.2",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-web": "^0.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
@ -1406,7 +1412,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz",
|
||||
"integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
@ -1917,6 +1922,15 @@
|
||||
"resolve-from": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro-runtime": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-5.0.4.tgz",
|
||||
"integrity": "sha512-r694MeO+7Vi8IwOsDIDzH/Q5RPMt1kUDYbiTJwnO15nIqiDwlE8HU55UlRhffKZy6s5FmxQsZ8HA+T8DqUW8cQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/osascript": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.2.5.tgz",
|
||||
@ -2380,9 +2394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-async-storage/async-storage": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
||||
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.1.2.tgz",
|
||||
"integrity": "sha512-dvlNq4AlGWC+ehtH12p65+17V0Dx7IecOWl6WanF2ja38O1Dcjjvn7jVzkUHJ5oWkQBlyASurTPlTHgKXyYiow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"merge-options": "^3.0.4"
|
||||
@ -3414,6 +3428,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/bplist-creator": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
||||
@ -3945,6 +3965,15 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -3968,6 +3997,65 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-in-js-utils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hyphenate-style-name": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.14",
|
||||
"source-map": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@ -4081,6 +4169,61 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
@ -4141,6 +4284,18 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-editor": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
|
||||
@ -4342,6 +4497,17 @@
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-linear-gradient": {
|
||||
"version": "14.1.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.1.5.tgz",
|
||||
"integrity": "sha512-BSN3MkSGLZoHMduEnAgfhoj3xqcDWaoICgIr4cIYEx1GcHfKMhzA/O4mpZJ/WC27BP1rnAqoKfbclk1eA70ndQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "2.1.14",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.1.14.tgz",
|
||||
@ -4410,6 +4576,36 @@
|
||||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fbjs": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
|
||||
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-fetch": "^3.1.5",
|
||||
"fbjs-css-vars": "^1.0.0",
|
||||
"loose-envify": "^1.0.0",
|
||||
"object-assign": "^4.1.0",
|
||||
"promise": "^7.1.1",
|
||||
"setimmediate": "^1.0.5",
|
||||
"ua-parser-js": "^1.0.35"
|
||||
}
|
||||
},
|
||||
"node_modules/fbjs-css-vars": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
|
||||
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fbjs/node_modules/promise": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
|
||||
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asap": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@ -4641,6 +4837,36 @@
|
||||
"graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/graphql-ws": {
|
||||
"version": "6.0.6",
|
||||
"resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.6.tgz",
|
||||
"integrity": "sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fastify/websocket": "^10 || ^11",
|
||||
"crossws": "~0.3",
|
||||
"graphql": "^15.10.1 || ^16",
|
||||
"uWebSockets.js": "^20",
|
||||
"ws": "^8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@fastify/websocket": {
|
||||
"optional": true
|
||||
},
|
||||
"crossws": {
|
||||
"optional": true
|
||||
},
|
||||
"uWebSockets.js": {
|
||||
"optional": true
|
||||
},
|
||||
"ws": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@ -4748,6 +4974,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/hyphenate-style-name": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
|
||||
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@ -4846,6 +5078,15 @@
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inline-style-prefixer": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
|
||||
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-in-js-utils": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
@ -5659,6 +5900,12 @@
|
||||
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
@ -6192,6 +6439,26 @@
|
||||
"integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
||||
@ -6249,6 +6516,18 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/nullthrows": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
||||
@ -6667,6 +6946,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pretty-bytes": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||
@ -6870,6 +7155,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
|
||||
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.25.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-freeze": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
|
||||
@ -6958,9 +7255,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.27.2.tgz",
|
||||
"integrity": "sha512-+kNaY2m7uQu5+5ls8os6z92DTk9expsEAYsaPv30n08mrqX2r64G8iVGDwNWzZcId54+P7RlDnhyszTql0sQ0w==",
|
||||
"version": "2.24.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.24.0.tgz",
|
||||
"integrity": "sha512-ZdWyOd1C8axKJHIfYxjJKCcxjWEpUtUWgTOVY2wynbiveSQDm8X/PDyAKXSer/GOtIpjudUbACOndZXCN3vHsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
@ -7050,37 +7347,44 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-reanimated": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.0.1.tgz",
|
||||
"integrity": "sha512-SZmIpxVd1yijV1MA8KB9S9TUj6JpdU4THjVB0WCkfV9p6F8oR3YxO4e+GRKbNci3mODp7plW095LhjaCB9bqZQ==",
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz",
|
||||
"integrity": "sha512-SxBK7wQfJ4UoWoJqQnmIC7ZjuNgVb9rcY5Xc67upXAFKftWg0rnkknTw6vgwnjRcvYThrjzUVti66XoZdDJGtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "^1.2.1",
|
||||
"semver": "7.7.2"
|
||||
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
|
||||
"@babel/plugin-transform-class-properties": "^7.0.0-0",
|
||||
"@babel/plugin-transform-classes": "^7.0.0-0",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.0.0-0",
|
||||
"@babel/plugin-transform-shorthand-properties": "^7.0.0-0",
|
||||
"@babel/plugin-transform-template-literals": "^7.0.0-0",
|
||||
"@babel/plugin-transform-unicode-regex": "^7.0.0-0",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"invariant": "^2.2.4",
|
||||
"react-native-is-edge-to-edge": "1.1.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-worklets": ">=0.3.0"
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-reanimated/node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.7.tgz",
|
||||
"integrity": "sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-safe-area-context": {
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.5.2.tgz",
|
||||
"integrity": "sha512-t4YVbHa9uAGf+pHMabGrb0uHrD5ogAusSu842oikJ3YKXcYp6iB4PTGl0EZNkUIR3pCnw/CXKn42OCfhsS0JIw==",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.4.0.tgz",
|
||||
"integrity": "sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
@ -7088,13 +7392,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-screens": {
|
||||
"version": "4.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.13.1.tgz",
|
||||
"integrity": "sha512-EESsMAtyzYcL3gpAI2NKKiIo+Ew0fnX4P4b3Zy/+MTc6SJIo3foJbZwdIWd/SUBswOf7IYCvWBppg+D8tbwnsw==",
|
||||
"version": "4.11.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.11.1.tgz",
|
||||
"integrity": "sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-freeze": "^1.0.0",
|
||||
"react-native-is-edge-to-edge": "^1.2.1",
|
||||
"react-native-is-edge-to-edge": "^1.1.7",
|
||||
"warn-once": "^0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -7102,6 +7406,21 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-svg": {
|
||||
"version": "15.11.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.11.2.tgz",
|
||||
"integrity": "sha512-+YfF72IbWQUKzCIydlijV1fLuBsQNGMT6Da2kFlo1sh+LE3BIm/2Q7AR1zAAR6L0BFLi1WaQPLfFUC9bNZpOmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
"css-tree": "^1.1.3",
|
||||
"warn-once": "0.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-vector-icons": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz",
|
||||
@ -7189,30 +7508,38 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-worklets": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.4.1.tgz",
|
||||
"integrity": "sha512-QXAMZ8jz0sLEoNrc3ej050z6Sd+UJ/Gef4SACeMuoLRinwHIy4uel7XtMPJZMqKhFerkwXZ7Ips5vIjnNyPDBA==",
|
||||
"node_modules/react-native-web": {
|
||||
"version": "0.20.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.20.0.tgz",
|
||||
"integrity": "sha512-OOSgrw+aON6R3hRosCau/xVxdLzbjEcsLysYedka0ZON4ZZe6n9xgeN9ZkoejhARM36oTlUgHIQqxGutEJ9Wxg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
|
||||
"@babel/plugin-transform-class-properties": "^7.0.0-0",
|
||||
"@babel/plugin-transform-classes": "^7.0.0-0",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.0.0-0",
|
||||
"@babel/plugin-transform-shorthand-properties": "^7.0.0-0",
|
||||
"@babel/plugin-transform-template-literals": "^7.0.0-0",
|
||||
"@babel/plugin-transform-unicode-regex": "^7.0.0-0",
|
||||
"@babel/preset-typescript": "^7.16.7",
|
||||
"convert-source-map": "^2.0.0"
|
||||
"@babel/runtime": "^7.18.6",
|
||||
"@react-native/normalize-colors": "^0.74.1",
|
||||
"fbjs": "^3.0.4",
|
||||
"inline-style-prefixer": "^7.0.1",
|
||||
"memoize-one": "^6.0.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"styleq": "^0.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-web/node_modules/@react-native/normalize-colors": {
|
||||
"version": "0.74.89",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz",
|
||||
"integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-web/node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
|
||||
"version": "0.79.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.5.tgz",
|
||||
@ -7781,6 +8108,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@ -8117,6 +8450,12 @@
|
||||
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/styleq": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
|
||||
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
@ -8395,6 +8734,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
@ -8451,6 +8796,32 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.40",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz",
|
||||
"integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ua-parser-js": "script/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.21.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
|
||||
@ -8656,6 +9027,16 @@
|
||||
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url-without-unicode": {
|
||||
"version": "8.0.0-3",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz",
|
||||
@ -8670,6 +9051,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
@ -10,23 +10,29 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.13.9",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@expo/metro-runtime": "~5.0.4",
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-navigation/bottom-tabs": "^7.4.5",
|
||||
"@react-navigation/native": "^7.1.17",
|
||||
"@react-navigation/native-stack": "^7.3.24",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~53.0.20",
|
||||
"expo-linear-gradient": "~14.1.5",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"graphql": "^16.11.0",
|
||||
"graphql-ws": "^6.0.6",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native": "0.79.5",
|
||||
"react-native-gesture-handler": "^2.27.2",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||
"react-native-paper": "^5.14.5",
|
||||
"react-native-reanimated": "^4.0.1",
|
||||
"react-native-safe-area-context": "^5.5.2",
|
||||
"react-native-screens": "^4.13.1",
|
||||
"react-native-vector-icons": "^10.3.0"
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-svg": "15.11.2",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-web": "^0.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
|
252
frontend/src/components/BackgroundDesign.tsx
Normal file
252
frontend/src/components/BackgroundDesign.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, Dimensions } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
withSequence,
|
||||
interpolate,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
interface BackgroundDesignProps {
|
||||
variant?: 'default' | 'login' | 'chat';
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BackgroundDesign: React.FC<BackgroundDesignProps> = ({
|
||||
variant = 'default',
|
||||
children
|
||||
}) => {
|
||||
const floatAnimation = useSharedValue(0);
|
||||
const rotateAnimation = useSharedValue(0);
|
||||
const pulseAnimation = useSharedValue(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Плавное движение вверх-вниз
|
||||
floatAnimation.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 4000, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
|
||||
// Вращение элементов
|
||||
rotateAnimation.value = withRepeat(
|
||||
withTiming(360, { duration: 30000, easing: Easing.linear }),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
|
||||
// Пульсация
|
||||
pulseAnimation.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}, []);
|
||||
|
||||
const floatingStyle = useAnimatedStyle(() => {
|
||||
const translateY = interpolate(floatAnimation.value, [0, 1], [0, -30]);
|
||||
return {
|
||||
transform: [{ translateY }],
|
||||
};
|
||||
});
|
||||
|
||||
const rotatingStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ rotate: `${rotateAnimation.value}deg` }],
|
||||
};
|
||||
});
|
||||
|
||||
const pulsingStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pulseAnimation.value, [0, 1], [1, 1.1]);
|
||||
const opacity = interpolate(pulseAnimation.value, [0, 1], [0.3, 0.6]);
|
||||
return {
|
||||
transform: [{ scale }],
|
||||
opacity,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Основной градиентный фон */}
|
||||
<LinearGradient
|
||||
colors={['#0a0a0a', '#1a1a1a', '#0f0f0f']}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* Декоративные элементы */}
|
||||
<View style={styles.decorativeElements}>
|
||||
{/* Большой круг с градиентом слева вверху */}
|
||||
<Animated.View style={[styles.circle1, pulsingStyle]}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.01)']}
|
||||
style={styles.circleGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Плавающий элемент справа */}
|
||||
<Animated.View style={[styles.floatingElement1, floatingStyle]}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.05)', 'rgba(255,255,255,0.02)']}
|
||||
style={styles.elementGradient}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Вращающийся квадрат */}
|
||||
<Animated.View style={[styles.rotatingSquare, rotatingStyle]}>
|
||||
<View style={styles.squareInner} />
|
||||
</Animated.View>
|
||||
|
||||
{/* Сетка точек для login варианта */}
|
||||
{variant === 'login' && (
|
||||
<View style={styles.dotsGrid}>
|
||||
{Array.from({ length: 10 }).map((_, i) =>
|
||||
Array.from({ length: 15 }).map((_, j) => (
|
||||
<View
|
||||
key={`${i}-${j}`}
|
||||
style={[
|
||||
styles.dot,
|
||||
{
|
||||
left: i * (width / 9),
|
||||
top: j * (height / 14),
|
||||
opacity: 0.05 + Math.random() * 0.05,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Градиентные блики */}
|
||||
<View style={styles.glowContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.1)', 'transparent']}
|
||||
style={[styles.glow1]}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.08)', 'transparent']}
|
||||
style={[styles.glow2]}
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Контент поверх фона */}
|
||||
<View style={styles.contentContainer}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0a0a0a',
|
||||
},
|
||||
decorativeElements: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
},
|
||||
circle1: {
|
||||
position: 'absolute',
|
||||
top: -width * 0.2,
|
||||
left: -width * 0.2,
|
||||
width: width * 0.6,
|
||||
height: width * 0.6,
|
||||
borderRadius: width * 0.3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
circleGradient: {
|
||||
flex: 1,
|
||||
},
|
||||
floatingElement1: {
|
||||
position: 'absolute',
|
||||
top: height * 0.2,
|
||||
right: width * 0.1,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: 'rgba(255,255,255,0.02)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
elementGradient: {
|
||||
flex: 1,
|
||||
},
|
||||
rotatingSquare: {
|
||||
position: 'absolute',
|
||||
bottom: height * 0.15,
|
||||
left: width * 0.15,
|
||||
width: 60,
|
||||
height: 60,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
transform: [{ rotate: '45deg' }],
|
||||
},
|
||||
squareInner: {
|
||||
flex: 1,
|
||||
margin: 10,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
dotsGrid: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: width,
|
||||
height: height,
|
||||
},
|
||||
dot: {
|
||||
position: 'absolute',
|
||||
width: 2,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#666666',
|
||||
},
|
||||
glowContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
glow1: {
|
||||
position: 'absolute',
|
||||
top: height * 0.1,
|
||||
right: -width * 0.2,
|
||||
width: width * 0.8,
|
||||
height: width * 0.8,
|
||||
borderRadius: width * 0.4,
|
||||
},
|
||||
glow2: {
|
||||
position: 'absolute',
|
||||
bottom: -height * 0.1,
|
||||
left: -width * 0.1,
|
||||
width: width * 0.7,
|
||||
height: width * 0.7,
|
||||
borderRadius: width * 0.35,
|
||||
},
|
||||
});
|
@ -17,4 +17,15 @@ const getApiUrl = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export const API_URL = getApiUrl();
|
||||
// Для физических устройств в сети используйте IP адрес компьютера
|
||||
export const DEV_SERVER_IP = '192.168.31.210';
|
||||
|
||||
export const getDeviceApiUrl = () => {
|
||||
if (__DEV__) {
|
||||
return `http://${DEV_SERVER_IP}:3000/graphql`;
|
||||
}
|
||||
return getApiUrl();
|
||||
};
|
||||
|
||||
// Используйте getDeviceApiUrl() для физических устройств, getApiUrl() для эмуляторов
|
||||
export const API_URL = getDeviceApiUrl();
|
@ -88,3 +88,22 @@ export const MARK_MESSAGE_AS_READ = gql`
|
||||
}
|
||||
${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 { LoginScreen } from '../screens/LoginScreen';
|
||||
import { RegisterScreen } from '../screens/RegisterScreen';
|
||||
import { ConversationsScreen } from '../screens/ConversationsScreen';
|
||||
import { ChatScreen } from '../screens/ChatScreen';
|
||||
import { MainNavigator } from './MainNavigator';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
|
||||
const Stack = createNativeStackNavigator();
|
||||
@ -25,23 +24,11 @@ export const AppNavigator = () => {
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator>
|
||||
{user ? (
|
||||
<>
|
||||
<Stack.Screen
|
||||
name="Conversations"
|
||||
component={ConversationsScreen}
|
||||
options={{
|
||||
title: 'Чаты',
|
||||
headerLargeTitle: true,
|
||||
}}
|
||||
name="Main"
|
||||
component={MainNavigator}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Chat"
|
||||
component={ChatScreen}
|
||||
options={({ route }) => ({
|
||||
title: route.params?.title || 'Чат',
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Stack.Screen
|
||||
|
144
frontend/src/navigation/MainNavigator.tsx
Normal file
144
frontend/src/navigation/MainNavigator.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
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 { NewMessageScreen } from '../screens/NewMessageScreen';
|
||||
import { UserInfoScreen } from '../screens/UserInfoScreen';
|
||||
|
||||
// Заглушки для новых экранов (создадим позже)
|
||||
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={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<ChatsStack.Screen
|
||||
name="Chat"
|
||||
component={ChatScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<ChatsStack.Screen
|
||||
name="NewMessage"
|
||||
component={NewMessageScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<ChatsStack.Screen
|
||||
name="UserInfo"
|
||||
component={UserInfoScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</ChatsStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
// Стек навигации для контактов
|
||||
function ContactsStackNavigator() {
|
||||
return (
|
||||
<ContactsStack.Navigator>
|
||||
<ContactsStack.Screen
|
||||
name="ContactsList"
|
||||
component={ContactsScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { TextInput, IconButton, Text, Avatar, Surface, Menu } from 'react-native-paper';
|
||||
import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
|
||||
import { TextInput, IconButton, Text, Avatar, Surface, Menu, useTheme } from 'react-native-paper';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useQuery, useMutation, useSubscription } from '@apollo/client';
|
||||
import { GET_MESSAGES } from '../graphql/queries';
|
||||
import { SEND_MESSAGE, DELETE_MESSAGE } from '../graphql/mutations';
|
||||
@ -9,17 +10,32 @@ import { Message } from '../types';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { format } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeOutDown,
|
||||
Layout,
|
||||
SlideInRight,
|
||||
SlideInLeft,
|
||||
FadeInUp,
|
||||
} from 'react-native-reanimated';
|
||||
import { BackgroundDesign } from '../components/BackgroundDesign';
|
||||
|
||||
export const ChatScreen = ({ route }: any) => {
|
||||
const { conversationId, title } = route.params;
|
||||
export const ChatScreen = ({ route, navigation }: any) => {
|
||||
const { conversationId, title, otherUser } = route.params;
|
||||
const { user } = useAuth();
|
||||
const theme = useTheme();
|
||||
const [message, setMessage] = useState('');
|
||||
const [selectedMessage, setSelectedMessage] = useState<string | null>(null);
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [chatMenuVisible, setChatMenuVisible] = useState(false);
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const textInputRef = useRef<TextInput>(null);
|
||||
|
||||
const { data, loading, error } = useQuery(GET_MESSAGES, {
|
||||
variables: { conversationId },
|
||||
pollInterval: 1000, // Опрос каждую секунду для лучшего UX
|
||||
fetchPolicy: 'cache-and-network', // Всегда проверять сеть
|
||||
});
|
||||
|
||||
const [sendMessage] = useMutation(SEND_MESSAGE, {
|
||||
@ -57,15 +73,54 @@ export const ChatScreen = ({ route }: any) => {
|
||||
},
|
||||
});
|
||||
|
||||
useSubscription(MESSAGE_ADDED, {
|
||||
variables: { conversationId },
|
||||
onData: ({ data }) => {
|
||||
if (data?.data?.messageAdded && data.data.messageAdded.sender.id !== user?.id) {
|
||||
// Сообщение от другого пользователя
|
||||
flatListRef.current?.scrollToEnd();
|
||||
// Временно отключаем подписки, используем только polling
|
||||
// useSubscription(MESSAGE_ADDED, {
|
||||
// variables: { conversationId },
|
||||
// onError: (error) => {
|
||||
// console.error('Subscription error:', error);
|
||||
// },
|
||||
// onData: ({ data, client }) => {
|
||||
// console.log('Subscription data received:', data);
|
||||
// const newMessage = data?.data?.messageAdded;
|
||||
// if (newMessage) {
|
||||
// console.log('New message received:', newMessage.content);
|
||||
// // Обновляем кеш с новым сообщением
|
||||
// const existingData = client.readQuery({
|
||||
// query: GET_MESSAGES,
|
||||
// variables: { conversationId },
|
||||
// });
|
||||
|
||||
// if (existingData) {
|
||||
// // Проверяем, не существует ли уже это сообщение в кеше
|
||||
// const messageExists = existingData.messages.some((msg: any) => msg.id === newMessage.id);
|
||||
|
||||
// if (!messageExists) {
|
||||
// client.writeQuery({
|
||||
// query: GET_MESSAGES,
|
||||
// variables: { conversationId },
|
||||
// data: {
|
||||
// messages: [...existingData.messages, newMessage],
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Прокручиваем к концу только если это не наше сообщение
|
||||
// if (newMessage.sender.id !== user?.id) {
|
||||
// setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// });
|
||||
|
||||
// Автопрокрутка при изменении сообщений
|
||||
useEffect(() => {
|
||||
if (data?.messages?.length > 0) {
|
||||
setTimeout(() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [data?.messages?.length]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) return;
|
||||
@ -78,7 +133,7 @@ export const ChatScreen = ({ route }: any) => {
|
||||
},
|
||||
});
|
||||
setMessage('');
|
||||
setTimeout(() => flatListRef.current?.scrollToEnd(), 100);
|
||||
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
}
|
||||
@ -94,35 +149,108 @@ export const ChatScreen = ({ route }: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessage = ({ item }: { item: Message }) => {
|
||||
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
|
||||
const isOwnMessage = item.sender.id === user?.id;
|
||||
const messageTime = format(new Date(item.createdAt), 'HH:mm', { locale: ru });
|
||||
|
||||
return (
|
||||
<View style={[styles.messageContainer, isOwnMessage && styles.ownMessageContainer]}>
|
||||
<Animated.View
|
||||
entering={isOwnMessage ? SlideInRight.delay(index * 30) : SlideInLeft.delay(index * 30)}
|
||||
exiting={FadeOutDown}
|
||||
style={[styles.messageContainer, isOwnMessage && styles.ownMessageContainer]}
|
||||
>
|
||||
{/* Аватар для входящих сообщений */}
|
||||
{!isOwnMessage && (
|
||||
<View style={styles.messageAvatarContainer}>
|
||||
<Avatar.Text
|
||||
size={36}
|
||||
label={item.sender.username.charAt(0).toUpperCase()}
|
||||
style={styles.avatar}
|
||||
label={(item.sender.username || item.sender.email || item.sender.id).charAt(0).toUpperCase()}
|
||||
style={[styles.messageAvatar, { backgroundColor: theme.colors.surfaceVariant }]}
|
||||
labelStyle={[styles.messageAvatarLabel, { color: theme.colors.onSurfaceVariant }]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<Surface
|
||||
style={[styles.messageBubble, isOwnMessage && styles.ownMessageBubble]}
|
||||
elevation={1}
|
||||
|
||||
{/* Контейнер сообщения */}
|
||||
<View style={[styles.messageBubbleContainer, isOwnMessage && styles.ownMessageBubbleContainer]}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onLongPress={() => {
|
||||
if (isOwnMessage) {
|
||||
setSelectedMessage(item.id);
|
||||
setMenuVisible(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={[
|
||||
styles.messageBubble,
|
||||
isOwnMessage ? styles.ownMessageBubble : styles.incomingMessageBubble
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={isOwnMessage
|
||||
? [theme.colors.primary, theme.colors.primaryContainer]
|
||||
: ['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']
|
||||
}
|
||||
style={styles.messageBubbleGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* Имя отправителя для входящих сообщений */}
|
||||
{!isOwnMessage && (
|
||||
<Text variant="labelSmall" style={styles.senderName}>
|
||||
{item.sender.username}
|
||||
<Text
|
||||
variant="labelSmall"
|
||||
style={[styles.senderName, { color: theme.colors.primary }]}
|
||||
>
|
||||
{item.sender.username || item.sender.email?.split('@')[0] || 'Пользователь'}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.messageText, isOwnMessage && styles.ownMessageText]}>
|
||||
|
||||
{/* Текст сообщения */}
|
||||
<Text
|
||||
style={[
|
||||
styles.messageText,
|
||||
{
|
||||
color: isOwnMessage
|
||||
? theme.colors.onPrimary
|
||||
: theme.colors.onSurface
|
||||
}
|
||||
]}
|
||||
>
|
||||
{item.content}
|
||||
</Text>
|
||||
<Text variant="bodySmall" style={[styles.messageTime, isOwnMessage && styles.ownMessageTime]}>
|
||||
|
||||
{/* Время и статус */}
|
||||
<View style={styles.messageFooter}>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={[
|
||||
styles.messageTime,
|
||||
{
|
||||
color: isOwnMessage
|
||||
? 'rgba(255,255,255,0.7)'
|
||||
: theme.colors.onSurfaceVariant
|
||||
}
|
||||
]}
|
||||
>
|
||||
{messageTime}
|
||||
{item.isEdited && ' • изменено'}
|
||||
{item.isEdited && ' • изм.'}
|
||||
</Text>
|
||||
|
||||
{/* Статус прочтения для исходящих */}
|
||||
{isOwnMessage && (
|
||||
<MaterialCommunityIcons
|
||||
name={item.isRead ? "check-all" : "check"}
|
||||
size={16}
|
||||
color={item.isRead ? theme.colors.primary : 'rgba(255,255,255,0.5)'}
|
||||
style={styles.readStatus}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Меню для собственных сообщений */}
|
||||
{isOwnMessage && (
|
||||
<Menu
|
||||
visible={menuVisible && selectedMessage === item.id}
|
||||
@ -130,39 +258,172 @@ export const ChatScreen = ({ route }: any) => {
|
||||
setMenuVisible(false);
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
anchor={
|
||||
<IconButton
|
||||
icon="dots-vertical"
|
||||
size={16}
|
||||
onPress={() => {
|
||||
setSelectedMessage(item.id);
|
||||
setMenuVisible(true);
|
||||
}}
|
||||
style={styles.menuButton}
|
||||
/>
|
||||
}
|
||||
anchor={<View />}
|
||||
contentStyle={[styles.messageMenuContent, { backgroundColor: theme.colors.surface }]}
|
||||
>
|
||||
<Menu.Item onPress={() => handleDeleteMessage(item.id)} title="Удалить" />
|
||||
<Menu.Item
|
||||
onPress={() => handleDeleteMessage(item.id)}
|
||||
title="Удалить"
|
||||
leadingIcon="delete"
|
||||
titleStyle={{ color: theme.colors.error }}
|
||||
/>
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
setMenuVisible(false);
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
title="Редактировать"
|
||||
leadingIcon="pencil"
|
||||
titleStyle={{ color: theme.colors.onSurface }}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
</Surface>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<Text>Загрузка сообщений...</Text>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>Загрузка сообщений...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BackgroundDesign variant="chat">
|
||||
{/* Компактный заголовок */}
|
||||
<Animated.View
|
||||
style={styles.headerContainer}
|
||||
entering={FadeInUp.duration(600)}
|
||||
>
|
||||
<View style={styles.headerCard}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.headerGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.headerContent}>
|
||||
{/* Кнопка назад */}
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="arrow-left"
|
||||
size={24}
|
||||
color={theme.colors.onSurface}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Информация о пользователе */}
|
||||
<TouchableOpacity
|
||||
style={styles.userInfo}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => navigation.navigate('UserInfo', { user: otherUser })}
|
||||
>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Avatar.Text
|
||||
size={40}
|
||||
label={(title || 'U').charAt(0).toUpperCase()}
|
||||
style={[styles.headerAvatar, { backgroundColor: theme.colors.primaryContainer }]}
|
||||
labelStyle={[styles.avatarLabel, { color: theme.colors.onPrimaryContainer }]}
|
||||
/>
|
||||
{/* Статус онлайн */}
|
||||
<View style={styles.onlineIndicator}>
|
||||
<View style={styles.onlineDot} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.userDetails}>
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
style={[styles.userName, { color: theme.colors.onSurface }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title || 'Пользователь'}
|
||||
</Text>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={[styles.userStatus, { color: theme.colors.onSurfaceVariant }]}
|
||||
>
|
||||
в сети
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Меню действий */}
|
||||
<Menu
|
||||
visible={chatMenuVisible}
|
||||
onDismiss={() => setChatMenuVisible(false)}
|
||||
anchor={
|
||||
<TouchableOpacity
|
||||
style={styles.menuButton}
|
||||
onPress={() => setChatMenuVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="dots-vertical"
|
||||
size={24}
|
||||
color={theme.colors.onSurface}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
>
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
setChatMenuVisible(false);
|
||||
// TODO: Открыть поиск по чату
|
||||
}}
|
||||
title="Поиск по чату"
|
||||
leadingIcon="magnify"
|
||||
/>
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
setChatMenuVisible(false);
|
||||
// TODO: Отметить как непрочитанное
|
||||
}}
|
||||
title="Не прочитано"
|
||||
leadingIcon="message-badge"
|
||||
/>
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
setChatMenuVisible(false);
|
||||
// TODO: Информация о чате
|
||||
}}
|
||||
title="Информация о чате"
|
||||
leadingIcon="information"
|
||||
/>
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
setChatMenuVisible(false);
|
||||
// TODO: Отключить уведомления
|
||||
}}
|
||||
title="Без уведомлений"
|
||||
leadingIcon="bell-off"
|
||||
/>
|
||||
<Menu.Item
|
||||
onPress={() => {
|
||||
setChatMenuVisible(false);
|
||||
// TODO: Очистить историю
|
||||
}}
|
||||
title="Очистить историю"
|
||||
leadingIcon="delete-sweep"
|
||||
/>
|
||||
</Menu>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Сообщения */}
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
style={styles.keyboardAvoidingView}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={90}
|
||||
keyboardVerticalOffset={100}
|
||||
>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
@ -170,94 +431,435 @@ export const ChatScreen = ({ route }: any) => {
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.messagesList}
|
||||
onContentSizeChange={() => flatListRef.current?.scrollToEnd()}
|
||||
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
|
||||
onLayout={() => flatListRef.current?.scrollToEnd({ animated: false })}
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={false}
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 100,
|
||||
}}
|
||||
/>
|
||||
<View style={styles.inputContainer}>
|
||||
|
||||
{/* Поле ввода */}
|
||||
<Animated.View
|
||||
style={styles.inputContainer}
|
||||
entering={FadeInUp.delay(400).duration(600)}
|
||||
>
|
||||
{/* Основной контейнер ввода */}
|
||||
<View style={styles.inputWrapper}>
|
||||
<LinearGradient
|
||||
colors={['rgba(26, 26, 26, 0.95)', 'rgba(26, 26, 26, 0.98)']}
|
||||
style={styles.inputWrapperGradient}
|
||||
/>
|
||||
|
||||
{/* Левые кнопки */}
|
||||
<View style={styles.leftButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
// TODO: Прикрепить файл
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="paperclip"
|
||||
size={22}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
// TODO: Камера
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="camera"
|
||||
size={22}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Поле ввода */}
|
||||
<View style={styles.textInputWrapper}>
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
placeholder="Введите сообщение..."
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
placeholder="Сообщение..."
|
||||
placeholderTextColor="rgba(255,255,255,0.5)"
|
||||
style={styles.messageInput}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon="send"
|
||||
onPress={handleSend}
|
||||
disabled={!message.trim()}
|
||||
/>
|
||||
onSubmitEditing={() => {
|
||||
if (message.trim()) {
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Правые кнопки */}
|
||||
<View style={styles.rightButtons}>
|
||||
{message.trim() ? (
|
||||
<TouchableOpacity
|
||||
style={styles.sendButtonContainer}
|
||||
activeOpacity={0.8}
|
||||
onPress={handleSend}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[theme.colors.primary, theme.colors.primaryContainer]}
|
||||
style={styles.sendButtonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="send"
|
||||
size={20}
|
||||
color={theme.colors.onPrimary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
// TODO: Голосовое сообщение
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="microphone"
|
||||
size={22}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
// TODO: Эмодзи
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="emoticon-outline"
|
||||
size={22}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</KeyboardAvoidingView>
|
||||
</BackgroundDesign>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
backgroundColor: '#0a0a0a',
|
||||
},
|
||||
centerContainer: {
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Заголовок
|
||||
headerContainer: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
headerCard: {
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 30,
|
||||
elevation: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
headerGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 20,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
marginRight: 12,
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
marginRight: 12,
|
||||
},
|
||||
headerAvatar: {
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
onlineIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.9)',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(26, 26, 26, 0.9)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
onlineDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#4CAF50',
|
||||
},
|
||||
userDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
userName: {
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
marginBottom: 2,
|
||||
},
|
||||
userStatus: {
|
||||
fontSize: 12,
|
||||
opacity: 0.8,
|
||||
},
|
||||
menuButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
marginLeft: 8,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#0a0a0a',
|
||||
},
|
||||
loadingText: {
|
||||
color: '#666666',
|
||||
fontSize: 16,
|
||||
},
|
||||
messagesList: {
|
||||
padding: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
// Сообщения
|
||||
messageContainer: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 12,
|
||||
marginBottom: 16,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
ownMessageContainer: {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
avatar: {
|
||||
messageAvatarContainer: {
|
||||
marginRight: 8,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
messageAvatar: {
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
messageAvatarLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
messageBubbleContainer: {
|
||||
maxWidth: '75%',
|
||||
position: 'relative',
|
||||
},
|
||||
ownMessageBubbleContainer: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
messageBubble: {
|
||||
maxWidth: '75%',
|
||||
padding: 12,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 18,
|
||||
padding: 16,
|
||||
minWidth: 80,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
ownMessageBubble: {
|
||||
backgroundColor: '#2196F3',
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.8)',
|
||||
marginLeft: 60,
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
incomingMessageBubble: {
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.6)',
|
||||
marginRight: 60,
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
messageBubbleGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 18,
|
||||
},
|
||||
senderName: {
|
||||
color: '#666',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
messageText: {
|
||||
color: '#000',
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
marginBottom: 6,
|
||||
},
|
||||
ownMessageText: {
|
||||
color: '#fff',
|
||||
messageFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
messageTime: {
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
},
|
||||
ownMessageTime: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
readStatus: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
menuButton: {
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
messageMenuContent: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
// Поле ввода
|
||||
inputContainer: {
|
||||
padding: 8,
|
||||
backgroundColor: '#fff',
|
||||
backgroundColor: 'transparent',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
input: {
|
||||
maxHeight: 100,
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
position: 'relative',
|
||||
minHeight: 60,
|
||||
},
|
||||
inputWrapperGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
leftButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
},
|
||||
rightButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
},
|
||||
actionButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
},
|
||||
textInputWrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 4,
|
||||
minHeight: 40,
|
||||
maxHeight: 120,
|
||||
},
|
||||
messageInput: {
|
||||
fontSize: 16,
|
||||
color: '#ffffff',
|
||||
lineHeight: 20,
|
||||
textAlignVertical: 'center',
|
||||
paddingVertical: 8,
|
||||
minHeight: 32,
|
||||
},
|
||||
sendButtonContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
},
|
||||
sendButtonGradient: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 20,
|
||||
},
|
||||
});
|
671
frontend/src/screens/ContactsScreen.tsx
Normal file
671
frontend/src/screens/ContactsScreen.tsx
Normal file
@ -0,0 +1,671 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, FlatList, TouchableOpacity, Dimensions } from 'react-native';
|
||||
import { Searchbar, Avatar, Text, Chip, IconButton, SegmentedButtons, ActivityIndicator, useTheme, Headline } 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';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeInRight,
|
||||
FadeInUp,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withSpring,
|
||||
withSequence,
|
||||
withRepeat,
|
||||
withDelay,
|
||||
interpolate,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
import { BackgroundDesign } from '../components/BackgroundDesign';
|
||||
|
||||
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 theme = useTheme();
|
||||
|
||||
// Анимации
|
||||
const headerScale = useSharedValue(0.95);
|
||||
const headerOpacity = useSharedValue(0);
|
||||
const listAnimation = useSharedValue(0);
|
||||
const statsAnimation = useSharedValue(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(() => {
|
||||
// Анимация появления заголовка
|
||||
headerScale.value = withSpring(1, { damping: 15, stiffness: 100 });
|
||||
headerOpacity.value = withTiming(1, { duration: 800 });
|
||||
|
||||
// Анимация списка
|
||||
listAnimation.value = withTiming(1, { duration: 600, easing: Easing.out(Easing.ease) });
|
||||
|
||||
// Анимация статистики
|
||||
statsAnimation.value = withDelay(200, withTiming(1, { duration: 500 }));
|
||||
}, []);
|
||||
|
||||
const getOtherParticipantName = (conversation: any) => {
|
||||
const otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id);
|
||||
return otherParticipant?.username || otherParticipant?.email || 'Чат';
|
||||
};
|
||||
|
||||
const handleStartChat = async (userId: string) => {
|
||||
setCreatingChatWithId(userId);
|
||||
try {
|
||||
await createConversation({
|
||||
variables: { recipientId: userId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating conversation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Анимированные стили
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: headerScale.value }],
|
||||
opacity: headerOpacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const listAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateY = interpolate(listAnimation.value, [0, 1], [30, 0]);
|
||||
return {
|
||||
transform: [{ translateY }],
|
||||
opacity: listAnimation.value,
|
||||
};
|
||||
});
|
||||
|
||||
const statsAnimatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(statsAnimation.value, [0, 1], [0.8, 1]);
|
||||
return {
|
||||
transform: [{ scale }],
|
||||
opacity: statsAnimation.value,
|
||||
};
|
||||
});
|
||||
|
||||
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
|
||||
entering={FadeInDown.delay(index * 50).springify()}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => handleStartChat(item.id)}
|
||||
disabled={isCreatingChat}
|
||||
>
|
||||
<View style={styles.userCard}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']}
|
||||
style={styles.userGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.userContent}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.1)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.avatarGradient}
|
||||
/>
|
||||
<Avatar.Text
|
||||
size={60}
|
||||
label={(item.username || item.email || item.id).charAt(0).toUpperCase()}
|
||||
style={[styles.avatar, { backgroundColor: getAvatarColor(item.username || item.email || item.id) }]}
|
||||
labelStyle={[styles.avatarLabel, { color: '#ffffff' }]}
|
||||
/>
|
||||
{item.isOnline === true && (
|
||||
<View style={styles.onlineIndicator}>
|
||||
<View style={styles.onlineBadge} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.userInfo}>
|
||||
<View style={styles.userHeader}>
|
||||
<Text variant="titleMedium" style={[styles.username, { color: theme.colors.onSurface }]}>
|
||||
@{item.username || item.email?.split('@')[0] || item.id.slice(0, 8)}
|
||||
</Text>
|
||||
{item.isOnline === true && (
|
||||
<View style={styles.onlineChip}>
|
||||
<Text style={[styles.onlineChipText, { color: '#4CAF50' }]}>В сети</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text variant="bodyMedium" style={[styles.userBio, { color: theme.colors.onSurfaceVariant }]} numberOfLines={2}>
|
||||
{item.bio || item.email || 'Пользователь'}
|
||||
</Text>
|
||||
|
||||
{item.isOnline !== true && item.lastSeen && (
|
||||
<Text variant="bodySmall" style={[styles.lastSeen, { color: theme.colors.onSurfaceVariant }]}>
|
||||
Был(а) {formatLastSeen(item.lastSeen)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.actionContainer}>
|
||||
{isCreatingChat ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => handleStartChat(item.id)}
|
||||
style={styles.messageButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[theme.colors.primary, theme.colors.primaryContainer]}
|
||||
style={styles.messageButtonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="message-plus-outline"
|
||||
size={20}
|
||||
color={theme.colors.onPrimary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
// Вспомогательные функции
|
||||
const getAvatarColor = (identifier: string) => {
|
||||
const colors = ['#2196F3', '#4CAF50', '#FF5722', '#9C27B0', '#FF9800', '#00BCD4'];
|
||||
const index = identifier.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 === true && u.id !== currentUser?.id).length || 0;
|
||||
const totalUsersCount = data?.users?.filter((u: User) => u.id !== currentUser?.id).length || 0;
|
||||
|
||||
return (
|
||||
<BackgroundDesign variant="chat">
|
||||
{/* Заголовок */}
|
||||
<Animated.View
|
||||
style={[styles.headerContainer, headerAnimatedStyle]}
|
||||
entering={FadeInUp.delay(200).duration(600)}
|
||||
>
|
||||
<View style={styles.headerCard}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.headerGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.logoContainer}>
|
||||
<View style={[styles.logoPlaceholder, { backgroundColor: theme.colors.surfaceVariant }]}>
|
||||
<MaterialCommunityIcons name="account-group" size={32} color={theme.colors.onSurface} />
|
||||
</View>
|
||||
</View>
|
||||
<Headline style={[styles.title, { color: theme.colors.onSurface }]}>КОНТАКТЫ</Headline>
|
||||
</View>
|
||||
|
||||
{/* Поисковая строка */}
|
||||
<Searchbar
|
||||
placeholder="Поиск по @username..."
|
||||
onChangeText={setSearchQuery}
|
||||
value={searchQuery}
|
||||
style={[styles.searchbar, { backgroundColor: theme.colors.surfaceVariant }]}
|
||||
inputStyle={{ color: theme.colors.onSurface }}
|
||||
placeholderTextColor={theme.colors.onSurfaceVariant}
|
||||
iconColor={theme.colors.onSurfaceVariant}
|
||||
theme={{
|
||||
colors: {
|
||||
primary: theme.colors.primary,
|
||||
text: theme.colors.onSurface,
|
||||
placeholder: theme.colors.onSurfaceVariant,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Статистика */}
|
||||
<Animated.View style={[styles.statsContainer, statsAnimatedStyle]}>
|
||||
<View style={styles.statItem}>
|
||||
<Text variant="headlineSmall" style={[styles.statNumber, { color: theme.colors.onSurface }]}>
|
||||
{totalUsersCount}
|
||||
</Text>
|
||||
<Text variant="bodySmall" style={[styles.statLabel, { color: theme.colors.onSurfaceVariant }]}>
|
||||
пользователей
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.statDivider, { backgroundColor: theme.colors.outlineVariant }]} />
|
||||
<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, { color: theme.colors.onSurfaceVariant }]}>
|
||||
в сети
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Табы */}
|
||||
<Animated.View style={[styles.tabsContainer, listAnimatedStyle]}>
|
||||
<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, { backgroundColor: theme.colors.surfaceVariant }]}
|
||||
theme={{
|
||||
colors: {
|
||||
primary: theme.colors.primary,
|
||||
onSurface: theme.colors.onSurface,
|
||||
surface: theme.colors.surface,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Список пользователей */}
|
||||
<Animated.View style={[styles.listContainer, listAnimatedStyle]}>
|
||||
<FlatList
|
||||
data={filteredUsers.filter((user: User) => {
|
||||
if (selectedTab === 'online') return user.isOnline === true;
|
||||
return true;
|
||||
})}
|
||||
renderItem={renderUser}
|
||||
keyExtractor={(item) => item.id}
|
||||
onRefresh={refetch}
|
||||
refreshing={loading}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<Animated.View
|
||||
style={styles.emptyContainer}
|
||||
entering={FadeInDown.duration(600)}
|
||||
>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']}
|
||||
style={styles.emptyIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<MaterialCommunityIcons
|
||||
name={searchQuery ? 'account-search' : 'account-group-outline'}
|
||||
size={48}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
</View>
|
||||
<Text variant="headlineSmall" style={[styles.emptyText, { color: theme.colors.onSurface }]}>
|
||||
{searchQuery
|
||||
? `@${searchQuery} не найден`
|
||||
: selectedTab === 'online'
|
||||
? 'Нет пользователей онлайн'
|
||||
: 'Нет доступных пользователей'}
|
||||
</Text>
|
||||
{searchQuery && (
|
||||
<Text variant="bodyLarge" style={[styles.emptySubtext, { color: theme.colors.onSurfaceVariant }]}>
|
||||
Проверьте правильность никнейма
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
}
|
||||
/>
|
||||
</Animated.View>
|
||||
</BackgroundDesign>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Заголовок
|
||||
headerContainer: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
headerCard: {
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 30,
|
||||
elevation: 20,
|
||||
},
|
||||
headerGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
headerContent: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
logoContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
logoPlaceholder: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 15,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '300',
|
||||
letterSpacing: 3,
|
||||
marginBottom: 8,
|
||||
},
|
||||
searchbar: {
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
elevation: 0,
|
||||
marginBottom: 20,
|
||||
},
|
||||
|
||||
// Статистика
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
statDivider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
statNumber: {
|
||||
fontWeight: '600',
|
||||
fontSize: 24,
|
||||
},
|
||||
statLabel: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
onlineStatContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
// Табы
|
||||
tabsContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
tabs: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
|
||||
// Список
|
||||
listContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
|
||||
// Карточка пользователя
|
||||
userCard: {
|
||||
marginVertical: 6,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.6)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 15,
|
||||
elevation: 5,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
userGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 20,
|
||||
},
|
||||
userContent: {
|
||||
flexDirection: 'row',
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
marginRight: 16,
|
||||
},
|
||||
avatarGradient: {
|
||||
position: 'absolute',
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
},
|
||||
avatar: {
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
avatarLabel: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
onlineIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 3,
|
||||
borderColor: 'rgba(26, 26, 26, 0.8)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
onlineBadge: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#4CAF50',
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
userHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
username: {
|
||||
fontWeight: '600',
|
||||
fontSize: 17,
|
||||
},
|
||||
onlineChip: {
|
||||
marginLeft: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.15)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(76, 175, 80, 0.3)',
|
||||
},
|
||||
onlineChipText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
userBio: {
|
||||
lineHeight: 20,
|
||||
fontSize: 15,
|
||||
},
|
||||
lastSeen: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
},
|
||||
actionContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
messageButton: {
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
messageButtonGradient: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
// Empty состояние
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
paddingTop: 80,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.6)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
emptyIconGradient: {
|
||||
position: 'absolute',
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
fontWeight: '300',
|
||||
letterSpacing: 0.5,
|
||||
fontSize: 24,
|
||||
},
|
||||
emptySubtext: {
|
||||
textAlign: 'center',
|
||||
maxWidth: 280,
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
@ -1,151 +1,895 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
|
||||
import { List, Avatar, Text, FAB, Divider, Badge } 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 { useQuery } from "@apollo/client";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { format, isToday, isYesterday } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Menu,
|
||||
Searchbar,
|
||||
Text,
|
||||
useTheme,
|
||||
} from "react-native-paper";
|
||||
import Animated, {
|
||||
Easing,
|
||||
FadeInDown,
|
||||
FadeInRight,
|
||||
FadeInUp,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { BackgroundDesign } from "../components/BackgroundDesign";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { GET_CONVERSATIONS } from "../graphql/queries";
|
||||
import { Conversation } from "../types";
|
||||
|
||||
type UserStatus = "online" | "away" | "busy" | "invisible";
|
||||
|
||||
export const ConversationsScreen = ({ navigation }: any) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const [userStatus, setUserStatus] = useState<UserStatus>("online");
|
||||
const [statusMenuVisible, setStatusMenuVisible] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const theme = useTheme();
|
||||
|
||||
// Анимации
|
||||
const headerScale = useSharedValue(0.95);
|
||||
const headerOpacity = useSharedValue(0);
|
||||
const listAnimation = useSharedValue(0);
|
||||
const searchAnimation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Анимация появления заголовка
|
||||
headerScale.value = withSpring(1, { damping: 15, stiffness: 100 });
|
||||
headerOpacity.value = withTiming(1, { duration: 800 });
|
||||
|
||||
// Анимация списка
|
||||
listAnimation.value = withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.ease),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Анимация поиска
|
||||
searchAnimation.value = withTiming(isSearchMode ? 1 : 0, {
|
||||
duration: 300,
|
||||
easing: Easing.bezier(0.4, 0.0, 0.2, 1),
|
||||
});
|
||||
}, [isSearchMode]);
|
||||
|
||||
const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS, {
|
||||
pollInterval: 5000, // Обновляем каждые 5 секунд
|
||||
pollInterval: 5000,
|
||||
});
|
||||
|
||||
const renderConversation = ({ item }: { item: Conversation }) => {
|
||||
const otherParticipant = item.participants.find(p => p.id !== data?.me?.id);
|
||||
const displayName = item.isGroup ? item.name : otherParticipant?.username;
|
||||
const lastMessageTime = item.lastMessage
|
||||
? format(new Date(item.lastMessage.createdAt), 'HH:mm', { locale: ru })
|
||||
: '';
|
||||
const formatMessageTime = (date: string) => {
|
||||
const messageDate = new Date(date);
|
||||
if (isToday(messageDate)) {
|
||||
return format(messageDate, "HH:mm", { locale: ru });
|
||||
} else if (isYesterday(messageDate)) {
|
||||
return "Вчера";
|
||||
} else {
|
||||
return format(messageDate, "dd.MM", { locale: ru });
|
||||
}
|
||||
};
|
||||
|
||||
// Фильтрация чатов по поисковому запросу
|
||||
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 || otherParticipant?.email);
|
||||
|
||||
return (
|
||||
displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
conv.lastMessage?.content
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}) || [];
|
||||
|
||||
// Функции статуса
|
||||
const getStatusInfo = (status: UserStatus) => {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return { icon: "circle", color: "#4CAF50", label: "В сети" };
|
||||
case "away":
|
||||
return { icon: "clock-outline", color: "#FF9800", label: "Отошел" };
|
||||
case "busy":
|
||||
return {
|
||||
icon: "minus-circle",
|
||||
color: "#F44336",
|
||||
label: "Не беспокоить",
|
||||
};
|
||||
case "invisible":
|
||||
return {
|
||||
icon: "eye-off-outline",
|
||||
color: "#9E9E9E",
|
||||
label: "Невидимый",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (newStatus: UserStatus) => {
|
||||
setUserStatus(newStatus);
|
||||
setStatusMenuVisible(false);
|
||||
// TODO: Здесь будет GraphQL мутация для обновления статуса
|
||||
};
|
||||
|
||||
const toggleSearchMode = () => {
|
||||
setIsSearchMode(!isSearchMode);
|
||||
if (isSearchMode) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewMessage = () => {
|
||||
navigation.navigate("NewMessage");
|
||||
};
|
||||
|
||||
// Анимированные стили
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: headerScale.value }],
|
||||
opacity: headerOpacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const listAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateY = interpolate(listAnimation.value, [0, 1], [30, 0]);
|
||||
return {
|
||||
transform: [{ translateY }],
|
||||
opacity: listAnimation.value,
|
||||
};
|
||||
});
|
||||
|
||||
const searchAnimatedStyle = useAnimatedStyle(() => {
|
||||
const height = interpolate(searchAnimation.value, [0, 1], [0, 60]);
|
||||
const opacity = searchAnimation.value;
|
||||
return {
|
||||
height,
|
||||
opacity,
|
||||
overflow: "hidden" as const,
|
||||
};
|
||||
});
|
||||
|
||||
const renderConversation = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: Conversation;
|
||||
index: number;
|
||||
}) => {
|
||||
const otherParticipant = item.participants.find((p) => p.id !== user?.id);
|
||||
const displayName = item.isGroup ? item.name : (otherParticipant?.username || otherParticipant?.email);
|
||||
const lastMessageTime = item.lastMessage
|
||||
? formatMessageTime(item.lastMessage.createdAt)
|
||||
: "";
|
||||
|
||||
// Подсчет непрочитанных сообщений (в будущем добавить в GraphQL)
|
||||
const unreadCount = 0;
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(index * 50).springify()}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Chat', { conversationId: item.id, title: displayName })}
|
||||
onPress={() =>
|
||||
navigation.navigate("Chat", {
|
||||
conversationId: item.id,
|
||||
title: displayName,
|
||||
})
|
||||
}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<List.Item
|
||||
title={displayName || 'Без имени'}
|
||||
description={item.lastMessage?.content || 'Нет сообщений'}
|
||||
left={() => (
|
||||
<View>
|
||||
<Avatar.Text
|
||||
size={50}
|
||||
label={displayName?.charAt(0).toUpperCase() || '?'}
|
||||
<View style={[styles.conversationItem]}>
|
||||
<LinearGradient
|
||||
colors={["rgba(255,255,255,0.03)", "rgba(255,255,255,0.08)"]}
|
||||
style={styles.conversationGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
{otherParticipant?.isOnline && (
|
||||
<Badge style={styles.onlineBadge} size={12} />
|
||||
|
||||
<View style={styles.avatarContainer}>
|
||||
<LinearGradient
|
||||
colors={["rgba(255,255,255,0.1)", "rgba(255,255,255,0.05)"]}
|
||||
style={styles.avatarGradient}
|
||||
/>
|
||||
<Avatar.Text
|
||||
size={56}
|
||||
label={displayName?.charAt(0).toUpperCase() || "?"}
|
||||
style={[
|
||||
styles.avatar,
|
||||
{ backgroundColor: theme.colors.surfaceVariant },
|
||||
]}
|
||||
labelStyle={[
|
||||
styles.avatarLabel,
|
||||
{ color: theme.colors.onSurface },
|
||||
]}
|
||||
/>
|
||||
{otherParticipant?.isOnline === true && (
|
||||
<Badge style={styles.onlineBadge} size={16} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
right={() => (
|
||||
<View style={styles.rightContent}>
|
||||
<Text variant="bodySmall" style={styles.time}>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
style={[
|
||||
styles.conversationTitle,
|
||||
{ color: theme.colors.onSurface },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayName || "Без имени"}
|
||||
</Text>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={[
|
||||
styles.time,
|
||||
{ color: theme.colors.onSurfaceVariant },
|
||||
]}
|
||||
>
|
||||
{lastMessageTime}
|
||||
</Text>
|
||||
{/* Здесь можно добавить счетчик непрочитанных сообщений */}
|
||||
</View>
|
||||
|
||||
<View style={styles.messageRow}>
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
style={[
|
||||
styles.lastMessage,
|
||||
{ color: theme.colors.onSurfaceVariant },
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.lastMessage?.content || "Нет сообщений"}
|
||||
</Text>
|
||||
{unreadCount > 0 && (
|
||||
<Badge style={styles.unreadBadge}>{unreadCount}</Badge>
|
||||
)}
|
||||
style={styles.listItem}
|
||||
/>
|
||||
<Divider />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<Text>Загрузка...</Text>
|
||||
<BackgroundDesign variant="chat">
|
||||
<View style={styles.loadingContainer}>
|
||||
<Animated.View
|
||||
style={[styles.loadingCard, headerAnimatedStyle]}
|
||||
entering={FadeInDown.duration(800)}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={["rgba(255,255,255,0.02)", "rgba(255,255,255,0.05)"]}
|
||||
style={styles.loadingGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.loadingText, { color: theme.colors.onSurface }]}
|
||||
>
|
||||
Загрузка чатов...
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</BackgroundDesign>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<Text>Ошибка загрузки чатов</Text>
|
||||
<BackgroundDesign variant="chat">
|
||||
<View style={styles.errorContainer}>
|
||||
<Animated.View
|
||||
style={[styles.errorCard, headerAnimatedStyle]}
|
||||
entering={FadeInDown.duration(800)}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={["rgba(255,255,255,0.02)", "rgba(255,255,255,0.05)"]}
|
||||
style={styles.errorGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Text style={[styles.errorText, { color: theme.colors.error }]}>
|
||||
Ошибка загрузки чатов
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => refetch()}
|
||||
style={[styles.retryButton]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[theme.colors.primary, theme.colors.primaryContainer]}
|
||||
style={styles.retryGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<Text
|
||||
style={[styles.retryText, { color: theme.colors.onPrimary }]}
|
||||
>
|
||||
Попробовать снова
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</BackgroundDesign>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackgroundDesign variant="chat">
|
||||
{/* Заголовок */}
|
||||
<Animated.View
|
||||
style={[styles.headerContainer, headerAnimatedStyle]}
|
||||
entering={FadeInUp.delay(200).duration(600)}
|
||||
>
|
||||
<View style={[styles.headerCard]}>
|
||||
<LinearGradient
|
||||
colors={["rgba(255,255,255,0.02)", "rgba(255,255,255,0.05)"]}
|
||||
style={styles.headerGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* Верхняя строка с статусом и поиском */}
|
||||
<View style={styles.topRow}>
|
||||
{/* Статус пользователя */}
|
||||
<Menu
|
||||
visible={statusMenuVisible}
|
||||
onDismiss={() => setStatusMenuVisible(false)}
|
||||
anchor={
|
||||
<TouchableOpacity
|
||||
style={styles.statusButton}
|
||||
onPress={() => setStatusMenuVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.statusIndicator,
|
||||
{ backgroundColor: getStatusInfo(userStatus).color },
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
{ color: theme.colors.onSurface },
|
||||
]}
|
||||
>
|
||||
{getStatusInfo(userStatus).label}
|
||||
</Text>
|
||||
<Text style={styles.statusIcon}>▼</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
>
|
||||
{(["online", "away", "busy", "invisible"] as UserStatus[]).map(
|
||||
(status) => (
|
||||
<Menu.Item
|
||||
key={status}
|
||||
onPress={() => handleStatusChange(status)}
|
||||
title={getStatusInfo(status).label}
|
||||
leadingIcon={getStatusInfo(status).icon}
|
||||
titleStyle={{ color: getStatusInfo(status).color }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
|
||||
{/* Кнопка поиска */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.searchToggle,
|
||||
isSearchMode && styles.searchToggleActive,
|
||||
]}
|
||||
onPress={toggleSearchMode}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
isSearchMode
|
||||
? [theme.colors.primary, theme.colors.primaryContainer]
|
||||
: ["rgba(255,255,255,0.1)", "rgba(255,255,255,0.05)"]
|
||||
}
|
||||
style={styles.searchToggleGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="magnify"
|
||||
size={20}
|
||||
color={
|
||||
isSearchMode
|
||||
? theme.colors.onPrimary
|
||||
: theme.colors.onSurface
|
||||
}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Поисковая строка (появляется при активации поиска) */}
|
||||
<Animated.View style={[styles.searchContainer, searchAnimatedStyle]}>
|
||||
<Searchbar
|
||||
placeholder="Поиск в чатах..."
|
||||
onChangeText={setSearchQuery}
|
||||
value={searchQuery}
|
||||
style={[
|
||||
styles.searchbar,
|
||||
{ backgroundColor: theme.colors.surfaceVariant },
|
||||
]}
|
||||
inputStyle={{ color: theme.colors.onSurface }}
|
||||
placeholderTextColor={theme.colors.onSurfaceVariant}
|
||||
iconColor={theme.colors.onSurfaceVariant}
|
||||
autoFocus={isSearchMode}
|
||||
theme={{
|
||||
colors: {
|
||||
primary: theme.colors.primary,
|
||||
text: theme.colors.onSurface,
|
||||
placeholder: theme.colors.onSurfaceVariant,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Список чатов */}
|
||||
<Animated.View style={[styles.listContainer, listAnimatedStyle]}>
|
||||
<FlatList
|
||||
data={data?.conversations || []}
|
||||
data={filteredConversations}
|
||||
renderItem={renderConversation}
|
||||
keyExtractor={(item) => item.id}
|
||||
onRefresh={refetch}
|
||||
refreshing={loading}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text variant="bodyLarge" style={styles.emptyText}>
|
||||
У вас пока нет чатов
|
||||
<Animated.View
|
||||
style={styles.emptyContainer}
|
||||
entering={FadeInDown.duration(600)}
|
||||
>
|
||||
<View style={[styles.emptyIconContainer]}>
|
||||
<LinearGradient
|
||||
colors={["rgba(255,255,255,0.03)", "rgba(255,255,255,0.08)"]}
|
||||
style={styles.emptyIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<MaterialCommunityIcons
|
||||
name="message-outline"
|
||||
size={48}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
variant="headlineSmall"
|
||||
style={[styles.emptyText, { color: theme.colors.onSurface }]}
|
||||
>
|
||||
Нет активных чатов
|
||||
</Text>
|
||||
<Text variant="bodyMedium" style={styles.emptySubtext}>
|
||||
<Text
|
||||
variant="bodyLarge"
|
||||
style={[
|
||||
styles.emptySubtext,
|
||||
{ color: theme.colors.onSurfaceVariant },
|
||||
]}
|
||||
>
|
||||
Начните новый чат, нажав на кнопку внизу
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
}
|
||||
/>
|
||||
<FAB
|
||||
icon="plus"
|
||||
style={styles.fab}
|
||||
onPress={() => navigation.navigate('NewChat')}
|
||||
</Animated.View>
|
||||
|
||||
{/* Разделитель */}
|
||||
<View style={styles.dividerContainer}>
|
||||
<LinearGradient
|
||||
colors={["transparent", theme.colors.outlineVariant, "transparent"]}
|
||||
style={styles.divider}
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Animated.View entering={FadeInRight.delay(300).springify()}>
|
||||
<TouchableOpacity
|
||||
onPress={handleNewMessage}
|
||||
style={styles.fabContainer}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[theme.colors.primary, theme.colors.primaryContainer]}
|
||||
style={styles.fab}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="pencil"
|
||||
size={28}
|
||||
color={theme.colors.onPrimary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</BackgroundDesign>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
centerContainer: {
|
||||
|
||||
// Loading состояние
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
},
|
||||
loadingCard: {
|
||||
borderRadius: 24,
|
||||
padding: 40,
|
||||
backgroundColor: "rgba(26, 26, 26, 0.8)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
alignItems: "center",
|
||||
shadowColor: "#ffffff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 30,
|
||||
elevation: 20,
|
||||
},
|
||||
loadingGradient: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 18,
|
||||
fontWeight: "300",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
|
||||
// Error состояние
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 20,
|
||||
},
|
||||
errorCard: {
|
||||
borderRadius: 24,
|
||||
padding: 40,
|
||||
backgroundColor: "rgba(26, 26, 26, 0.8)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
alignItems: "center",
|
||||
shadowColor: "#ffffff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 30,
|
||||
elevation: 20,
|
||||
},
|
||||
errorGradient: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 18,
|
||||
marginBottom: 24,
|
||||
textAlign: "center",
|
||||
fontWeight: "300",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
retryButton: {
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
retryGradient: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 32,
|
||||
alignItems: "center",
|
||||
},
|
||||
retryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 2,
|
||||
},
|
||||
|
||||
// Заголовок
|
||||
headerContainer: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
|
||||
topRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
|
||||
// Статус
|
||||
statusButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
statusIndicator: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
marginRight: 8,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
marginRight: 6,
|
||||
},
|
||||
statusIcon: {
|
||||
fontSize: 10,
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
},
|
||||
|
||||
// Поиск
|
||||
searchToggle: {
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
searchToggleActive: {
|
||||
shadowColor: "#ffffff",
|
||||
shadowOpacity: 0.3,
|
||||
},
|
||||
searchToggleGradient: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
|
||||
searchContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
headerCard: {
|
||||
borderRadius: 24,
|
||||
padding: 12,
|
||||
backgroundColor: "rgba(26, 26, 26, 0.8)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
shadowColor: "#ffffff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 30,
|
||||
elevation: 20,
|
||||
},
|
||||
headerGradient: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
searchbar: {
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
elevation: 0,
|
||||
},
|
||||
|
||||
// Список
|
||||
listContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
listItem: {
|
||||
paddingVertical: 8,
|
||||
conversationItem: {
|
||||
flexDirection: "row",
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 20,
|
||||
marginVertical: 6,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "rgba(26, 26, 26, 0.6)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
shadowColor: "#ffffff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 15,
|
||||
elevation: 5,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
rightContent: {
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
conversationGradient: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 20,
|
||||
},
|
||||
time: {
|
||||
color: '#666',
|
||||
avatarContainer: {
|
||||
position: "relative",
|
||||
marginRight: 16,
|
||||
},
|
||||
avatarGradient: {
|
||||
position: "absolute",
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
},
|
||||
avatar: {
|
||||
borderWidth: 2,
|
||||
borderColor: "rgba(255, 255, 255, 0.2)",
|
||||
},
|
||||
avatarLabel: {
|
||||
fontSize: 22,
|
||||
fontWeight: "600",
|
||||
},
|
||||
onlineBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#4CAF50',
|
||||
position: "absolute",
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
backgroundColor: "#4CAF50",
|
||||
borderWidth: 3,
|
||||
borderColor: "rgba(26, 26, 26, 0.8)",
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 6,
|
||||
},
|
||||
conversationTitle: {
|
||||
fontWeight: "600",
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
fontSize: 17,
|
||||
},
|
||||
time: {
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
},
|
||||
messageRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
lastMessage: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
},
|
||||
unreadBadge: {
|
||||
backgroundColor: "#2196F3",
|
||||
color: "#ffffff",
|
||||
fontSize: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
|
||||
// Разделитель
|
||||
dividerContainer: {
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
borderRadius: 0.5,
|
||||
},
|
||||
|
||||
// FAB
|
||||
fabContainer: {
|
||||
position: "absolute",
|
||||
right: 20,
|
||||
bottom: 100,
|
||||
borderRadius: 20,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
margin: 16,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 20,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
|
||||
// Empty состояние
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 40,
|
||||
paddingTop: 100,
|
||||
paddingTop: 80,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: 32,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
backgroundColor: "rgba(26, 26, 26, 0.6)",
|
||||
shadowColor: "#ffffff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
emptyIconGradient: {
|
||||
position: "absolute",
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
color: '#666',
|
||||
textAlign: "center",
|
||||
marginBottom: 12,
|
||||
fontWeight: "300",
|
||||
letterSpacing: 0.5,
|
||||
fontSize: 24,
|
||||
},
|
||||
emptySubtext: {
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
textAlign: "center",
|
||||
maxWidth: 280,
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
@ -1,15 +1,56 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||
import { TextInput, Button, Text, Headline, HelperText } from 'react-native-paper';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableOpacity } from 'react-native';
|
||||
import { TextInput, Button, Text, Headline, HelperText, Surface, useTheme } from 'react-native-paper';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { LOGIN } from '../graphql/mutations';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { BackgroundDesign } from '../components/BackgroundDesign';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withSpring,
|
||||
withDelay,
|
||||
withSequence,
|
||||
withRepeat,
|
||||
interpolate,
|
||||
Easing,
|
||||
FadeIn,
|
||||
FadeInDown,
|
||||
FadeInUp,
|
||||
} from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
export const LoginScreen = ({ navigation }: any) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const theme = useTheme();
|
||||
|
||||
// Анимации
|
||||
const cardScale = useSharedValue(0.95);
|
||||
const cardOpacity = useSharedValue(0);
|
||||
const glowAnimation = useSharedValue(0);
|
||||
const buttonScale = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
// Анимация появления карточки
|
||||
cardScale.value = withSpring(1, { damping: 15, stiffness: 100 });
|
||||
cardOpacity.value = withTiming(1, { duration: 800 });
|
||||
|
||||
// Мягкое свечение
|
||||
glowAnimation.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}, []);
|
||||
|
||||
const [loginMutation, { loading, error }] = useMutation(LOGIN, {
|
||||
onCompleted: async (data) => {
|
||||
@ -25,79 +66,188 @@ export const LoginScreen = ({ navigation }: any) => {
|
||||
loginMutation({ variables: { username, password } });
|
||||
};
|
||||
|
||||
// Анимированные стили
|
||||
const cardAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: cardScale.value }],
|
||||
opacity: cardOpacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const glowStyle = useAnimatedStyle(() => {
|
||||
const shadowOpacity = interpolate(glowAnimation.value, [0, 1], [0.1, 0.3]);
|
||||
const shadowRadius = interpolate(glowAnimation.value, [0, 1], [20, 40]);
|
||||
|
||||
return {
|
||||
shadowOpacity: shadowOpacity,
|
||||
shadowRadius: shadowRadius,
|
||||
};
|
||||
});
|
||||
|
||||
const buttonAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: buttonScale.value }],
|
||||
};
|
||||
});
|
||||
|
||||
const handleButtonPressIn = () => {
|
||||
buttonScale.value = withSpring(0.95);
|
||||
};
|
||||
|
||||
const handleButtonPressOut = () => {
|
||||
buttonScale.value = withSpring(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<BackgroundDesign variant="login">
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.content}>
|
||||
<Headline style={styles.title}>Вход в Prism</Headline>
|
||||
<Animated.View
|
||||
style={[styles.content, cardAnimatedStyle]}
|
||||
entering={FadeInDown.duration(800).springify()}
|
||||
>
|
||||
<Animated.View style={[styles.loginCard, glowStyle]}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeInUp.delay(200).duration(600)}
|
||||
style={styles.headerContainer}
|
||||
>
|
||||
<View style={styles.logoContainer}>
|
||||
<View style={[styles.logoPlaceholder, { backgroundColor: theme.colors.surfaceVariant }]}>
|
||||
<Text style={[styles.logoText, { color: theme.colors.onSurface }]}>P</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Headline style={[styles.title, { color: theme.colors.onSurface }]}>PRISM</Headline>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeIn.delay(400).duration(600)}
|
||||
style={styles.formContainer}
|
||||
>
|
||||
<TextInput
|
||||
label="Имя пользователя"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
mode="flat"
|
||||
style={[styles.input, { backgroundColor: theme.colors.surfaceVariant }]}
|
||||
autoCapitalize="none"
|
||||
disabled={loading}
|
||||
underlineColor="transparent"
|
||||
activeUnderlineColor={theme.colors.primary}
|
||||
theme={{
|
||||
colors: {
|
||||
primary: theme.colors.primary,
|
||||
placeholder: theme.colors.onSurfaceVariant,
|
||||
text: theme.colors.onSurface,
|
||||
background: 'transparent',
|
||||
outline: theme.colors.outline,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Пароль"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
mode="flat"
|
||||
style={[styles.input, { backgroundColor: theme.colors.surfaceVariant }]}
|
||||
secureTextEntry={!showPassword}
|
||||
disabled={loading}
|
||||
underlineColor="transparent"
|
||||
activeUnderlineColor={theme.colors.primary}
|
||||
theme={{
|
||||
colors: {
|
||||
primary: theme.colors.primary,
|
||||
placeholder: theme.colors.onSurfaceVariant,
|
||||
text: theme.colors.onSurface,
|
||||
background: 'transparent',
|
||||
outline: theme.colors.outline,
|
||||
}
|
||||
}}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon={showPassword ? 'eye-off' : 'eye'}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<HelperText type="error" visible={true}>
|
||||
<HelperText type="error" visible={true} style={[styles.errorText, { color: theme.colors.error }]}>
|
||||
{error.message}
|
||||
</HelperText>
|
||||
)}
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
<Animated.View style={buttonAnimatedStyle}>
|
||||
<TouchableOpacity
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
onPressIn={handleButtonPressIn}
|
||||
onPressOut={handleButtonPressOut}
|
||||
disabled={loading || !username || !password}
|
||||
style={styles.button}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
Войти
|
||||
</Button>
|
||||
<LinearGradient
|
||||
colors={[theme.colors.primary, theme.colors.primaryContainer]}
|
||||
style={[
|
||||
styles.gradientButton,
|
||||
(loading || !username || !password) && styles.disabledButton
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.onPrimary }]}>
|
||||
{loading ? 'ВХОД...' : 'ВОЙТИ'}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
<Button
|
||||
mode="text"
|
||||
<View style={styles.dividerContainer}>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />
|
||||
<Text style={[styles.dividerText, { color: theme.colors.onSurfaceVariant }]}>ИЛИ</Text>
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Register')}
|
||||
disabled={loading}
|
||||
style={styles.linkButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
Нет аккаунта? Зарегистрироваться
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={[styles.linkButtonText, { color: theme.colors.onSurfaceVariant }]}>
|
||||
Нет аккаунта?
|
||||
</Text>
|
||||
<Text style={[styles.linkButtonTextBold, { color: theme.colors.primary }]}>
|
||||
{' Создать'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</BackgroundDesign>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
@ -105,18 +255,122 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
loginCard: {
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
// iOS тени
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 30,
|
||||
// Android тень
|
||||
elevation: 20,
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
logoContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
logoPlaceholder: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 40,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 30,
|
||||
fontSize: 36,
|
||||
fontWeight: '300',
|
||||
letterSpacing: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
formContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
input: {
|
||||
marginBottom: 15,
|
||||
marginBottom: 20,
|
||||
fontSize: 16,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
button: {
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
errorText: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
fontSize: 14,
|
||||
},
|
||||
gradientButton: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
// iOS тени
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
// Android тень
|
||||
elevation: 8,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
dividerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 24,
|
||||
},
|
||||
divider: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
},
|
||||
dividerText: {
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
linkButton: {
|
||||
marginTop: 10,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
linkButtonText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
linkButtonTextBold: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
570
frontend/src/screens/NewMessageScreen.tsx
Normal file
570
frontend/src/screens/NewMessageScreen.tsx
Normal file
@ -0,0 +1,570 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, FlatList, TouchableOpacity, Share, Alert } from 'react-native';
|
||||
import { Searchbar, Avatar, Text, useTheme, Headline, IconButton, Divider } 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';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeInUp,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withSpring,
|
||||
interpolate,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
import { BackgroundDesign } from '../components/BackgroundDesign';
|
||||
|
||||
export const NewMessageScreen = ({ navigation }: any) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { user: currentUser } = useAuth();
|
||||
const theme = useTheme();
|
||||
|
||||
// Анимации
|
||||
const headerScale = useSharedValue(0.95);
|
||||
const headerOpacity = useSharedValue(0);
|
||||
const listAnimation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Анимация появления
|
||||
headerScale.value = withSpring(1, { damping: 15, stiffness: 100 });
|
||||
headerOpacity.value = withTiming(1, { duration: 800 });
|
||||
listAnimation.value = withTiming(1, { duration: 600, easing: Easing.out(Easing.ease) });
|
||||
}, []);
|
||||
|
||||
const { data, loading } = useQuery(GET_USERS);
|
||||
|
||||
const [createConversation] = useMutation(CREATE_PRIVATE_CONVERSATION, {
|
||||
onCompleted: (data) => {
|
||||
navigation.navigate('Chat', {
|
||||
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 || otherParticipant?.email || 'Чат';
|
||||
};
|
||||
|
||||
const handleStartChat = async (userId: string) => {
|
||||
try {
|
||||
await createConversation({
|
||||
variables: { recipientId: userId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating conversation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
Alert.alert('Новый групповой чат', 'Функция в разработке');
|
||||
};
|
||||
|
||||
const handleCreateChannel = () => {
|
||||
Alert.alert('Новый канал', 'Функция в разработке');
|
||||
};
|
||||
|
||||
const handleInviteFriends = async () => {
|
||||
try {
|
||||
await Share.share({
|
||||
message: 'Присоединяйся ко мне в Prism Messenger! Скачай приложение и давай общаться.',
|
||||
title: 'Приглашение в Prism',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sharing:', 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 headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: headerScale.value }],
|
||||
opacity: headerOpacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const listAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateY = interpolate(listAnimation.value, [0, 1], [30, 0]);
|
||||
return {
|
||||
transform: [{ translateY }],
|
||||
opacity: listAnimation.value,
|
||||
};
|
||||
});
|
||||
|
||||
const renderUser = ({ item, index }: { item: User; index: number }) => {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(index * 50).springify()}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => handleStartChat(item.id)}
|
||||
>
|
||||
<View style={styles.userCard}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']}
|
||||
style={styles.userGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.userContent}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Avatar.Text
|
||||
size={56}
|
||||
label={(item.username || item.email || item.id).charAt(0).toUpperCase()}
|
||||
style={[styles.avatar, { backgroundColor: getAvatarColor(item.username || item.email || item.id) }]}
|
||||
labelStyle={[styles.avatarLabel, { color: '#ffffff' }]}
|
||||
/>
|
||||
{item.isOnline === true && (
|
||||
<View style={styles.onlineIndicator}>
|
||||
<View style={styles.onlineBadge} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.userInfo}>
|
||||
<Text variant="titleMedium" style={[styles.username, { color: theme.colors.onSurface }]}>
|
||||
@{item.username || item.email?.split('@')[0] || item.id.slice(0, 8)}
|
||||
</Text>
|
||||
<Text variant="bodyMedium" style={[styles.userBio, { color: theme.colors.onSurfaceVariant }]} numberOfLines={1}>
|
||||
{item.bio || item.email || 'Пользователь'}
|
||||
</Text>
|
||||
{item.isOnline === true && (
|
||||
<View style={styles.onlineChip}>
|
||||
<Text style={[styles.onlineChipText, { color: '#4CAF50' }]}>В сети</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={24}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const getAvatarColor = (identifier: string) => {
|
||||
const colors = ['#2196F3', '#4CAF50', '#FF5722', '#9C27B0', '#FF9800', '#00BCD4'];
|
||||
const index = identifier.charCodeAt(0) % colors.length;
|
||||
return colors[index];
|
||||
};
|
||||
|
||||
return (
|
||||
<BackgroundDesign variant="chat">
|
||||
{/* Заголовок */}
|
||||
<Animated.View
|
||||
style={[styles.headerContainer, headerAnimatedStyle]}
|
||||
entering={FadeInUp.delay(200).duration(600)}
|
||||
>
|
||||
<View style={styles.headerCard}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.headerGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.topRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color={theme.colors.onSurface} />
|
||||
</TouchableOpacity>
|
||||
<Headline style={[styles.title, { color: theme.colors.onSurface }]}>НОВОЕ СООБЩЕНИЕ</Headline>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
{/* Поисковая строка */}
|
||||
<Searchbar
|
||||
placeholder="Поиск по @username..."
|
||||
onChangeText={setSearchQuery}
|
||||
value={searchQuery}
|
||||
style={[styles.searchbar, { backgroundColor: theme.colors.surfaceVariant }]}
|
||||
inputStyle={{ color: theme.colors.onSurface }}
|
||||
placeholderTextColor={theme.colors.onSurfaceVariant}
|
||||
iconColor={theme.colors.onSurfaceVariant}
|
||||
theme={{
|
||||
colors: {
|
||||
primary: theme.colors.primary,
|
||||
text: theme.colors.onSurface,
|
||||
placeholder: theme.colors.onSurfaceVariant,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Опции создания */}
|
||||
<Animated.View style={[styles.optionsContainer, listAnimatedStyle]}>
|
||||
<View style={styles.optionsCard}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.optionsGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<TouchableOpacity style={styles.option} onPress={handleCreateGroup} activeOpacity={0.8}>
|
||||
<View style={styles.optionIcon}>
|
||||
<MaterialCommunityIcons name="account-group" size={24} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text style={[styles.optionTitle, { color: theme.colors.onSurface }]}>Новый групповой чат</Text>
|
||||
<Text style={[styles.optionSubtitle, { color: theme.colors.onSurfaceVariant }]}>Создать группу до 256 участников</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Divider style={[styles.optionDivider, { backgroundColor: theme.colors.outlineVariant }]} />
|
||||
|
||||
<TouchableOpacity style={styles.option} onPress={handleCreateChannel} activeOpacity={0.8}>
|
||||
<View style={styles.optionIcon}>
|
||||
<MaterialCommunityIcons name="bullhorn" size={24} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text style={[styles.optionTitle, { color: theme.colors.onSurface }]}>Новый канал</Text>
|
||||
<Text style={[styles.optionSubtitle, { color: theme.colors.onSurfaceVariant }]}>Для публичных объявлений</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Divider style={[styles.optionDivider, { backgroundColor: theme.colors.outlineVariant }]} />
|
||||
|
||||
<TouchableOpacity style={styles.option} onPress={handleInviteFriends} activeOpacity={0.8}>
|
||||
<View style={styles.optionIcon}>
|
||||
<MaterialCommunityIcons name="account-plus" size={24} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text style={[styles.optionTitle, { color: theme.colors.onSurface }]}>Пригласить в мессенджер</Text>
|
||||
<Text style={[styles.optionSubtitle, { color: theme.colors.onSurfaceVariant }]}>Поделиться приглашением</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Список пользователей */}
|
||||
<Animated.View style={[styles.listContainer, listAnimatedStyle]}>
|
||||
<FlatList
|
||||
data={filteredUsers}
|
||||
renderItem={renderUser}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<Animated.View
|
||||
style={styles.emptyContainer}
|
||||
entering={FadeInDown.duration(600)}
|
||||
>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.03)', 'rgba(255,255,255,0.08)']}
|
||||
style={styles.emptyIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<MaterialCommunityIcons
|
||||
name={searchQuery ? 'account-search' : 'account-group-outline'}
|
||||
size={48}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
</View>
|
||||
<Text variant="headlineSmall" style={[styles.emptyText, { color: theme.colors.onSurface }]}>
|
||||
{searchQuery
|
||||
? `@${searchQuery} не найден`
|
||||
: 'Загружаем пользователей...'}
|
||||
</Text>
|
||||
{searchQuery && (
|
||||
<Text variant="bodyLarge" style={[styles.emptySubtext, { color: theme.colors.onSurfaceVariant }]}>
|
||||
Проверьте правильность никнейма
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
}
|
||||
/>
|
||||
</Animated.View>
|
||||
</BackgroundDesign>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Заголовок
|
||||
headerContainer: {
|
||||
paddingTop: 70,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
headerCard: {
|
||||
borderRadius: 24,
|
||||
padding: 16,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 30,
|
||||
elevation: 20,
|
||||
},
|
||||
headerGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '300',
|
||||
letterSpacing: 1,
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
placeholder: {
|
||||
width: 44,
|
||||
},
|
||||
searchbar: {
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
elevation: 0,
|
||||
},
|
||||
|
||||
// Опции
|
||||
optionsContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
optionsCard: {
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.6)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 15,
|
||||
elevation: 5,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
optionsGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 20,
|
||||
},
|
||||
option: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
optionIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
optionContent: {
|
||||
flex: 1,
|
||||
},
|
||||
optionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
optionSubtitle: {
|
||||
fontSize: 14,
|
||||
},
|
||||
optionDivider: {
|
||||
marginHorizontal: 20,
|
||||
height: 1,
|
||||
},
|
||||
|
||||
// Список
|
||||
listContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
|
||||
// Карточка пользователя
|
||||
userCard: {
|
||||
marginVertical: 6,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.6)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 15,
|
||||
elevation: 5,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
userGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 20,
|
||||
},
|
||||
userContent: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
marginRight: 16,
|
||||
},
|
||||
avatar: {
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
avatarLabel: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
onlineIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 3,
|
||||
borderColor: 'rgba(26, 26, 26, 0.8)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
onlineBadge: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#4CAF50',
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
username: {
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
userBio: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
onlineChip: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.15)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(76, 175, 80, 0.3)',
|
||||
},
|
||||
onlineChipText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// Empty состояние
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
paddingTop: 80,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.6)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
emptyIconGradient: {
|
||||
position: 'absolute',
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 60,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
fontWeight: '300',
|
||||
letterSpacing: 0.5,
|
||||
fontSize: 20,
|
||||
},
|
||||
emptySubtext: {
|
||||
textAlign: 'center',
|
||||
maxWidth: 280,
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
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 || user?.email || user?.id)?.charAt(0).toUpperCase() || 'U'}
|
||||
style={{ backgroundColor: theme.colors.primary }}
|
||||
/>
|
||||
<View style={styles.profileInfo}>
|
||||
<Text variant="headlineSmall" style={{ color: theme.colors.onSurface }}>
|
||||
@{user?.username || user?.email?.split('@')[0] || user?.id?.slice(0, 8)}
|
||||
</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 === true ? '#10b981' : '#6b7280'}
|
||||
/>
|
||||
<Text variant="bodySmall" style={{ color: user?.isOnline === true ? '#10b981' : '#6b7280', marginLeft: 4 }}>
|
||||
{user?.isOnline === true ? 'В сети' : 'Не в сети'}
|
||||
</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,
|
||||
},
|
||||
});
|
@ -1,9 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||
import { TextInput, Button, Text, Headline, HelperText } from 'react-native-paper';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableOpacity } from 'react-native';
|
||||
import { TextInput, Button, Text, Headline, HelperText, Surface } from 'react-native-paper';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { REGISTER } from '../graphql/mutations';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { BackgroundDesign } from '../components/BackgroundDesign';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withSpring,
|
||||
withDelay,
|
||||
withSequence,
|
||||
withRepeat,
|
||||
interpolate,
|
||||
Easing,
|
||||
FadeIn,
|
||||
FadeInDown,
|
||||
FadeInUp,
|
||||
} from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
export const RegisterScreen = ({ navigation }: any) => {
|
||||
const [username, setUsername] = useState('');
|
||||
@ -13,6 +31,28 @@ export const RegisterScreen = ({ navigation }: any) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
// Анимации
|
||||
const cardScale = useSharedValue(0.95);
|
||||
const cardOpacity = useSharedValue(0);
|
||||
const glowAnimation = useSharedValue(0);
|
||||
const buttonScale = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
// Анимация появления карточки
|
||||
cardScale.value = withSpring(1, { damping: 15, stiffness: 100 });
|
||||
cardOpacity.value = withTiming(1, { duration: 800 });
|
||||
|
||||
// Мягкое свечение
|
||||
glowAnimation.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}, []);
|
||||
|
||||
const [registerMutation, { loading, error }] = useMutation(REGISTER, {
|
||||
onCompleted: async (data) => {
|
||||
await login(data.register.access_token, data.register.user);
|
||||
@ -29,107 +69,236 @@ export const RegisterScreen = ({ navigation }: any) => {
|
||||
|
||||
const passwordsMatch = password === confirmPassword || confirmPassword === '';
|
||||
|
||||
// Анимированные стили
|
||||
const cardAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: cardScale.value }],
|
||||
opacity: cardOpacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const glowStyle = useAnimatedStyle(() => {
|
||||
const shadowOpacity = interpolate(glowAnimation.value, [0, 1], [0.1, 0.3]);
|
||||
const shadowRadius = interpolate(glowAnimation.value, [0, 1], [20, 40]);
|
||||
|
||||
return {
|
||||
shadowOpacity: shadowOpacity,
|
||||
shadowRadius: shadowRadius,
|
||||
};
|
||||
});
|
||||
|
||||
const buttonAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ scale: buttonScale.value }],
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleButtonPressIn = () => {
|
||||
buttonScale.value = withSpring(0.95);
|
||||
};
|
||||
|
||||
const handleButtonPressOut = () => {
|
||||
buttonScale.value = withSpring(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<BackgroundDesign variant="login">
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.content}>
|
||||
<Headline style={styles.title}>Регистрация в Prism</Headline>
|
||||
<Animated.View
|
||||
style={[styles.content, cardAnimatedStyle]}
|
||||
entering={FadeInDown.duration(800).springify()}
|
||||
>
|
||||
<Animated.View style={[styles.registerCard, glowStyle]}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeInUp.delay(200).duration(600)}
|
||||
style={styles.headerContainer}
|
||||
>
|
||||
<Headline style={styles.title}>РЕГИСТРАЦИЯ</Headline>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeIn.delay(400).duration(600)}
|
||||
style={styles.formContainer}
|
||||
>
|
||||
<TextInput
|
||||
label="Имя пользователя"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
mode="outlined"
|
||||
mode="flat"
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
disabled={loading}
|
||||
theme={{
|
||||
colors: {
|
||||
primary: '#ffffff',
|
||||
placeholder: '#808080',
|
||||
text: '#ffffff',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
outline: '#666666',
|
||||
}
|
||||
}}
|
||||
underlineColor="transparent"
|
||||
activeUnderlineColor="#ffffff"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
label="Электронная почта"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
mode="outlined"
|
||||
mode="flat"
|
||||
style={styles.input}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
disabled={loading}
|
||||
theme={{
|
||||
colors: {
|
||||
primary: '#ffffff',
|
||||
placeholder: '#808080',
|
||||
text: '#ffffff',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
outline: '#666666',
|
||||
}
|
||||
}}
|
||||
underlineColor="transparent"
|
||||
activeUnderlineColor="#ffffff"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Пароль"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
mode="outlined"
|
||||
mode="flat"
|
||||
style={styles.input}
|
||||
secureTextEntry={!showPassword}
|
||||
disabled={loading}
|
||||
theme={{
|
||||
colors: {
|
||||
primary: '#ffffff',
|
||||
placeholder: '#808080',
|
||||
text: '#ffffff',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
outline: '#666666',
|
||||
}
|
||||
}}
|
||||
right={
|
||||
<TextInput.Icon
|
||||
icon={showPassword ? 'eye-off' : 'eye'}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
color="#808080"
|
||||
/>
|
||||
}
|
||||
underlineColor="transparent"
|
||||
activeUnderlineColor="#ffffff"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Подтвердите пароль"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
mode="outlined"
|
||||
mode="flat"
|
||||
style={styles.input}
|
||||
secureTextEntry={!showPassword}
|
||||
disabled={loading}
|
||||
error={!passwordsMatch}
|
||||
theme={{
|
||||
colors: {
|
||||
primary: '#ffffff',
|
||||
placeholder: '#808080',
|
||||
text: '#ffffff',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
outline: passwordsMatch ? '#666666' : '#ff6b6b',
|
||||
error: '#ff6b6b',
|
||||
}
|
||||
}}
|
||||
underlineColor="transparent"
|
||||
activeUnderlineColor="#ffffff"
|
||||
/>
|
||||
|
||||
{!passwordsMatch && (
|
||||
<HelperText type="error" visible={true}>
|
||||
{!passwordsMatch && confirmPassword !== '' && (
|
||||
<HelperText type="error" visible={true} style={styles.errorText}>
|
||||
Пароли не совпадают
|
||||
</HelperText>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<HelperText type="error" visible={true}>
|
||||
<HelperText type="error" visible={true} style={styles.errorText}>
|
||||
{error.message}
|
||||
</HelperText>
|
||||
)}
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
<Animated.View style={buttonAnimatedStyle}>
|
||||
<TouchableOpacity
|
||||
onPress={handleRegister}
|
||||
loading={loading}
|
||||
onPressIn={handleButtonPressIn}
|
||||
onPressOut={handleButtonPressOut}
|
||||
disabled={loading || !username || !email || !password || !passwordsMatch}
|
||||
style={styles.button}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
Зарегистрироваться
|
||||
</Button>
|
||||
<LinearGradient
|
||||
colors={['#ffffff', '#e6e6e6']}
|
||||
style={[
|
||||
styles.gradientButton,
|
||||
(loading || !username || !email || !password || !passwordsMatch) && styles.disabledButton
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'СОЗДАНИЕ...' : 'СОЗДАТЬ АККАУНТ'}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
<Button
|
||||
mode="text"
|
||||
<View style={styles.dividerContainer}>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.dividerText}>ИЛИ</Text>
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('Login')}
|
||||
disabled={loading}
|
||||
style={styles.linkButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
Уже есть аккаунт? Войти
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={styles.linkButtonText}>
|
||||
Уже есть аккаунт?
|
||||
</Text>
|
||||
<Text style={styles.linkButtonTextBold}>
|
||||
{' Войти'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</BackgroundDesign>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
@ -137,18 +306,117 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
registerCard: {
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
// backdropFilter: 'blur(10px)', // не поддерживается в React Native
|
||||
// iOS тени
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 30,
|
||||
// Android тень
|
||||
elevation: 20,
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 30,
|
||||
fontSize: 28,
|
||||
fontWeight: '300',
|
||||
color: '#ffffff',
|
||||
letterSpacing: 3,
|
||||
marginBottom: 8,
|
||||
},
|
||||
|
||||
formContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
input: {
|
||||
marginBottom: 15,
|
||||
marginBottom: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
fontSize: 16,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
button: {
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
errorText: {
|
||||
color: '#ff6b6b',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
fontSize: 14,
|
||||
},
|
||||
gradientButton: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
// iOS тени
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
// Android тень
|
||||
elevation: 8,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#000000',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
dividerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 24,
|
||||
},
|
||||
divider: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
dividerText: {
|
||||
color: '#666666',
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 12,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
linkButton: {
|
||||
marginTop: 10,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
linkButtonText: {
|
||||
fontSize: 14,
|
||||
color: '#808080',
|
||||
},
|
||||
linkButtonTextBold: {
|
||||
fontSize: 14,
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
424
frontend/src/screens/UserInfoScreen.tsx
Normal file
424
frontend/src/screens/UserInfoScreen.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
|
||||
import { Text, Avatar, Divider, useTheme } from 'react-native-paper';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated';
|
||||
import { BackgroundDesign } from '../components/BackgroundDesign';
|
||||
import { format } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
|
||||
export const UserInfoScreen = ({ route, navigation }: any) => {
|
||||
const { user } = route.params;
|
||||
const theme = useTheme();
|
||||
|
||||
const formatJoinDate = (date: string | Date) => {
|
||||
return format(new Date(date), 'MMMM yyyy', { locale: ru });
|
||||
};
|
||||
|
||||
return (
|
||||
<BackgroundDesign variant="chat">
|
||||
{/* Заголовок */}
|
||||
<Animated.View
|
||||
style={styles.headerContainer}
|
||||
entering={FadeInUp.duration(600)}
|
||||
>
|
||||
<View style={styles.headerCard}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.headerGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.headerContent}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="arrow-left"
|
||||
size={24}
|
||||
color={theme.colors.onSurface}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text
|
||||
variant="titleLarge"
|
||||
style={[styles.headerTitle, { color: theme.colors.onSurface }]}
|
||||
>
|
||||
Информация
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||||
{/* Профиль пользователя */}
|
||||
<Animated.View
|
||||
style={styles.profileSection}
|
||||
entering={FadeInDown.delay(200).duration(600)}
|
||||
>
|
||||
<View style={styles.profileCard}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.profileGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.profileContent}>
|
||||
<View style={styles.avatarSection}>
|
||||
<Avatar.Text
|
||||
size={80}
|
||||
label={(user?.username || user?.email || user?.id || 'U').charAt(0).toUpperCase()}
|
||||
style={[styles.avatar, { backgroundColor: theme.colors.primaryContainer }]}
|
||||
labelStyle={[styles.avatarLabel, { color: theme.colors.onPrimaryContainer }]}
|
||||
/>
|
||||
{user?.isOnline === true && (
|
||||
<View style={styles.onlineIndicator}>
|
||||
<View style={styles.onlineDot} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.userDetails}>
|
||||
<Text
|
||||
variant="headlineSmall"
|
||||
style={[styles.userName, { color: theme.colors.onSurface }]}
|
||||
>
|
||||
@{user?.username || user?.email?.split('@')[0] || user?.id?.slice(0, 8)}
|
||||
</Text>
|
||||
|
||||
{user?.bio && (
|
||||
<Text
|
||||
variant="bodyLarge"
|
||||
style={[styles.userBio, { color: theme.colors.onSurfaceVariant }]}
|
||||
>
|
||||
{user.bio}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<MaterialCommunityIcons
|
||||
name="circle"
|
||||
size={12}
|
||||
color={user?.isOnline === true ? '#4CAF50' : '#9E9E9E'}
|
||||
/>
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
style={[
|
||||
styles.statusText,
|
||||
{
|
||||
color: user?.isOnline === true ? '#4CAF50' : theme.colors.onSurfaceVariant
|
||||
}
|
||||
]}
|
||||
>
|
||||
{user?.isOnline === true ? 'В сети' : 'Не в сети'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Дополнительная информация */}
|
||||
<Animated.View
|
||||
style={styles.infoSection}
|
||||
entering={FadeInDown.delay(400).duration(600)}
|
||||
>
|
||||
<View style={styles.infoCard}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.02)', 'rgba(255,255,255,0.05)']}
|
||||
style={styles.infoGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.infoContent}>
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
style={[styles.sectionTitle, { color: theme.colors.onSurface }]}
|
||||
>
|
||||
Детали
|
||||
</Text>
|
||||
|
||||
{user?.email && (
|
||||
<>
|
||||
<View style={styles.infoRow}>
|
||||
<MaterialCommunityIcons
|
||||
name="email"
|
||||
size={20}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
<View style={styles.infoTextContainer}>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={[styles.infoLabel, { color: theme.colors.onSurfaceVariant }]}
|
||||
>
|
||||
Email
|
||||
</Text>
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
style={[styles.infoValue, { color: theme.colors.onSurface }]}
|
||||
>
|
||||
{user.email}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Divider style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{user?.createdAt && (
|
||||
<>
|
||||
<View style={styles.infoRow}>
|
||||
<MaterialCommunityIcons
|
||||
name="calendar"
|
||||
size={20}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
<View style={styles.infoTextContainer}>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={[styles.infoLabel, { color: theme.colors.onSurfaceVariant }]}
|
||||
>
|
||||
Присоединился
|
||||
</Text>
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
style={[styles.infoValue, { color: theme.colors.onSurface }]}
|
||||
>
|
||||
{formatJoinDate(user.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Divider style={[styles.divider, { backgroundColor: theme.colors.outlineVariant }]} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{user?.lastSeen && user?.isOnline !== true && (
|
||||
<View style={styles.infoRow}>
|
||||
<MaterialCommunityIcons
|
||||
name="clock"
|
||||
size={20}
|
||||
color={theme.colors.onSurfaceVariant}
|
||||
/>
|
||||
<View style={styles.infoTextContainer}>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={[styles.infoLabel, { color: theme.colors.onSurfaceVariant }]}
|
||||
>
|
||||
Последний раз в сети
|
||||
</Text>
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
style={[styles.infoValue, { color: theme.colors.onSurface }]}
|
||||
>
|
||||
{format(new Date(user.lastSeen), 'dd MMMM, HH:mm', { locale: ru })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</BackgroundDesign>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Заголовок
|
||||
headerContainer: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
headerCard: {
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 30,
|
||||
elevation: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
headerGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 20,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
marginRight: 16,
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '300',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
|
||||
// Контент
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
|
||||
// Профиль
|
||||
profileSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
profileCard: {
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 30,
|
||||
elevation: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
profileGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
profileContent: {
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarSection: {
|
||||
position: 'relative',
|
||||
marginBottom: 20,
|
||||
},
|
||||
avatar: {
|
||||
borderWidth: 3,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
avatarLabel: {
|
||||
fontSize: 32,
|
||||
fontWeight: '600',
|
||||
},
|
||||
onlineIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.9)',
|
||||
borderWidth: 3,
|
||||
borderColor: 'rgba(26, 26, 26, 0.9)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
onlineDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#4CAF50',
|
||||
},
|
||||
userDetails: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
userName: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
userBio: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
lineHeight: 22,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statusText: {
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// Информация
|
||||
infoSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
infoCard: {
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(26, 26, 26, 0.6)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
shadowColor: '#ffffff',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 15,
|
||||
elevation: 5,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
infoGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 20,
|
||||
},
|
||||
infoContent: {
|
||||
padding: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
infoTextContainer: {
|
||||
marginLeft: 16,
|
||||
flex: 1,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 12,
|
||||
opacity: 0.8,
|
||||
marginBottom: 2,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
marginVertical: 4,
|
||||
opacity: 0.3,
|
||||
},
|
||||
});
|
@ -1,7 +1,10 @@
|
||||
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink } from '@apollo/client';
|
||||
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink, split } from '@apollo/client';
|
||||
import { setContext } from '@apollo/client/link/context';
|
||||
import { getMainDefinition } from '@apollo/client/utilities';
|
||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { API_URL } from '../config/api';
|
||||
import { API_URL, DEV_SERVER_IP } from '../config/api';
|
||||
|
||||
const httpLink = createHttpLink({
|
||||
uri: API_URL,
|
||||
@ -17,6 +20,41 @@ const authLink = setContext(async (_, { headers }) => {
|
||||
};
|
||||
});
|
||||
|
||||
// WebSocket линк для подписок
|
||||
const wsLink = new GraphQLWsLink(createClient({
|
||||
url: API_URL.replace('http://', 'ws://'),
|
||||
connectionParams: () => {
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
const token = await AsyncStorage.getItem('token');
|
||||
resolve({
|
||||
authorization: token ? `Bearer ${token}` : '',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting token for WebSocket:', error);
|
||||
resolve({
|
||||
authorization: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
retryAttempts: 5,
|
||||
retryWait: (attempt) => Math.min(1000 * Math.pow(2, attempt), 10000),
|
||||
}));
|
||||
|
||||
// Временно используем только HTTP линк, пока не настроим WebSocket
|
||||
// const splitLink = split(
|
||||
// ({ query }) => {
|
||||
// const definition = getMainDefinition(query);
|
||||
// return (
|
||||
// definition.kind === 'OperationDefinition' &&
|
||||
// definition.operation === 'subscription'
|
||||
// );
|
||||
// },
|
||||
// wsLink,
|
||||
// ApolloLink.from([authLink, httpLink])
|
||||
// );
|
||||
|
||||
export const apolloClient = new ApolloClient({
|
||||
link: ApolloLink.from([authLink, httpLink]),
|
||||
cache: new InMemoryCache(),
|
||||
|
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;
|
@ -1,10 +1,10 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
bio?: string;
|
||||
isOnline: boolean;
|
||||
isOnline?: boolean;
|
||||
lastSeen?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
Reference in New Issue
Block a user