Обновил дизайн на черно-серый

This commit is contained in:
albivkt
2025-08-06 06:14:55 +03:00
17 changed files with 1987 additions and 28 deletions

65
BUG_FIXES_SUMMARY.md Normal file
View 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
View File

@ -0,0 +1,44 @@
# Исправление ошибки lineHeight
## Проблема
При запуске приложения на iOS возникала ошибка:
```
Cannot read property 'lineHeight' of undefined
```
Ошибка происходила в компоненте Searchbar из React Native Paper при попытке получить доступ к настройкам шрифтов темы.
## Причина
В нашей кастомной темной теме не были определены настройки шрифтов (fonts). React Native Paper ожидает наличие конфигурации шрифтов с различными вариантами (displayLarge, bodyMedium и т.д.), каждый из которых должен содержать свойство lineHeight.
## Решение
Добавлены полные настройки шрифтов в тему:
1. Импортирован `configureFonts` из react-native-paper
2. Создана базовая конфигурация шрифтов с учетом платформы (iOS/Android)
3. Определены все необходимые варианты шрифтов с правильными значениями:
- fontSize
- lineHeight
- letterSpacing
- fontFamily
## Изменения в коде
```typescript
// Добавлены настройки шрифтов
fonts: configureFonts({
config: {
...baseFont,
bodyLarge: {
...baseFont,
fontSize: 16,
lineHeight: 24,
letterSpacing: 0.15,
},
// ... остальные варианты
},
}),
```
## Результат
Ошибка устранена, компонент Searchbar и другие компоненты React Native Paper теперь корректно работают с темой.

95
CONTACTS_FEATURE.md Normal file
View 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
View File

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

View File

@ -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> {

View File

@ -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);
}
}

View File

@ -55,10 +55,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);
}
}

View File

@ -2,11 +2,12 @@ import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { Platform } from 'react-native';
import { ApolloProvider } from '@apollo/client';
import { Provider as PaperProvider, MD3DarkTheme, configureFonts } from 'react-native-paper';
import { Provider as PaperProvider } from 'react-native-paper';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { apolloClient } from './src/services/apollo-client';
import { AuthProvider } from './src/contexts/AuthContext';
import { AppNavigator } from './src/navigation/AppNavigator';
<<<<<<< HEAD
// Современная черно-серая тема
const theme = {
@ -56,6 +57,9 @@ const theme = {
},
}),
};
=======
import { theme } from './src/theme';
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
export default function App() {
return (

View File

@ -87,4 +87,23 @@ 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}
`;

View File

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

View File

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

View File

@ -0,0 +1,427 @@
import React, { useState, useEffect } from 'react';
import { View, StyleSheet, FlatList, ScrollView, Animated, TouchableOpacity } from 'react-native';
import { Searchbar, List, Avatar, Text, Chip, FAB, Divider, IconButton, SegmentedButtons, Button, ActivityIndicator, Badge } from 'react-native-paper';
import { useQuery, useMutation } from '@apollo/client';
import { GET_USERS } from '../graphql/queries';
import { CREATE_PRIVATE_CONVERSATION } from '../graphql/mutations';
import { User } from '../types';
import { useAuth } from '../contexts/AuthContext';
import { MaterialCommunityIcons } from '@expo/vector-icons';
export const ContactsScreen = ({ navigation }: any) => {
const [searchQuery, setSearchQuery] = useState('');
const [selectedTab, setSelectedTab] = useState('all');
const [creatingChatWithId, setCreatingChatWithId] = useState<string | null>(null);
const { user: currentUser } = useAuth();
const fadeAnim = useState(new Animated.Value(0))[0];
const { data, loading, refetch } = useQuery(GET_USERS);
const [createConversation] = useMutation(CREATE_PRIVATE_CONVERSATION, {
onCompleted: (data) => {
navigation.navigate('Chats', {
screen: 'Chat',
params: {
conversationId: data.createPrivateConversation.id,
title: getOtherParticipantName(data.createPrivateConversation),
},
});
setCreatingChatWithId(null);
},
onError: () => {
setCreatingChatWithId(null);
}
});
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 500,
useNativeDriver: true,
}).start();
}, []);
const getOtherParticipantName = (conversation: any) => {
const otherParticipant = conversation.participants?.find((p: User) => p.id !== currentUser?.id);
return otherParticipant?.username || 'Чат';
};
const handleStartChat = async (userId: string) => {
setCreatingChatWithId(userId);
try {
await createConversation({
variables: { recipientId: userId },
});
} catch (error) {
console.error('Error creating conversation:', error);
}
};
const filteredUsers = data?.users?.filter((user: User) => {
if (user.id === currentUser?.id) return false;
if (searchQuery) {
return user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.toLowerCase());
}
return true;
}) || [];
const renderUser = ({ item, index }: { item: User; index: number }) => {
const isCreatingChat = creatingChatWithId === item.id;
return (
<Animated.View
style={{
opacity: fadeAnim,
transform: [{
translateY: fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [50, 0],
}),
}],
}}
>
<TouchableOpacity
activeOpacity={0.7}
onPress={() => handleStartChat(item.id)}
disabled={isCreatingChat}
>
<View style={[styles.userCard, styles.userCardShadow]}>
<View style={styles.userContent}>
<View style={styles.avatarContainer}>
<Avatar.Text
size={56}
label={item.username.charAt(0).toUpperCase()}
style={[styles.avatar, { backgroundColor: getAvatarColor(item.username) }]}
/>
{item.isOnline && (
<View style={styles.onlineIndicator}>
<MaterialCommunityIcons name="circle" size={14} color="#4CAF50" />
</View>
)}
</View>
<View style={styles.userInfo}>
<View style={styles.userHeader}>
<Text variant="titleMedium" style={styles.username}>
@{item.username}
</Text>
{item.isOnline && (
<Chip
mode="flat"
compact
style={styles.onlineChip}
textStyle={styles.onlineChipText}
>
В сети
</Chip>
)}
</View>
<Text variant="bodyMedium" style={styles.userBio} numberOfLines={2}>
{item.bio || `${item.email}`}
</Text>
{!item.isOnline && item.lastSeen && (
<Text variant="bodySmall" style={styles.lastSeen}>
Был(а) {formatLastSeen(item.lastSeen)}
</Text>
)}
</View>
<View style={styles.actionContainer}>
{isCreatingChat ? (
<ActivityIndicator size="small" color="#2196F3" />
) : (
<IconButton
icon="message-plus-outline"
size={28}
iconColor="#2196F3"
style={styles.messageButton}
onPress={() => handleStartChat(item.id)}
/>
)}
</View>
</View>
</View>
</TouchableOpacity>
</Animated.View>
);
};
// Вспомогательные функции
const getAvatarColor = (username: string) => {
const colors = ['#2196F3', '#4CAF50', '#FF5722', '#9C27B0', '#FF9800', '#00BCD4'];
const index = username.charCodeAt(0) % colors.length;
return colors[index];
};
const formatLastSeen = (date: Date | string) => {
const lastSeen = new Date(date);
const now = new Date();
const diffMs = now.getTime() - lastSeen.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'только что';
if (diffMins < 60) return `${diffMins} мин. назад`;
if (diffHours < 24) return `${diffHours} ч. назад`;
if (diffDays < 7) return `${diffDays} дн. назад`;
return lastSeen.toLocaleDateString('ru-RU');
};
// Подсчет статистики
const onlineUsersCount = data?.users?.filter((u: User) => u.isOnline && u.id !== currentUser?.id).length || 0;
const totalUsersCount = data?.users?.filter((u: User) => u.id !== currentUser?.id).length || 0;
return (
<View style={styles.container}>
{/* Заголовок с поиском */}
<View style={[styles.header, styles.headerShadow]}>
<View style={styles.searchContainer}>
<Searchbar
placeholder="Поиск по @username..."
onChangeText={setSearchQuery}
value={searchQuery}
style={styles.searchbar}
icon="account-search"
elevation={0}
/>
<Text variant="bodySmall" style={styles.searchHint}>
Введите никнейм для поиска пользователя
</Text>
</View>
{/* Статистика */}
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text variant="headlineSmall" style={styles.statNumber}>{totalUsersCount}</Text>
<Text variant="bodySmall" style={styles.statLabel}>пользователей</Text>
</View>
<View style={styles.statDivider} />
<View style={styles.statItem}>
<View style={styles.onlineStatContainer}>
<MaterialCommunityIcons name="circle" size={10} color="#4CAF50" style={{ marginRight: 4 }} />
<Text variant="headlineSmall" style={[styles.statNumber, { color: '#4CAF50' }]}>{onlineUsersCount}</Text>
</View>
<Text variant="bodySmall" style={styles.statLabel}>в сети</Text>
</View>
</View>
</View>
<SegmentedButtons
value={selectedTab}
onValueChange={setSelectedTab}
buttons={[
{
value: 'all',
label: 'Все',
icon: 'account-group',
},
{
value: 'online',
label: 'Онлайн',
icon: 'circle',
},
{
value: 'contacts',
label: 'Контакты',
icon: 'account-star',
},
]}
style={styles.tabs}
/>
<FlatList
data={filteredUsers.filter((user: User) => {
if (selectedTab === 'online') return user.isOnline;
// В будущем добавим логику для "Мои контакты"
return true;
})}
renderItem={renderUser}
keyExtractor={(item) => item.id}
onRefresh={refetch}
refreshing={loading}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name={searchQuery ? 'account-search' : 'account-group-outline'}
size={64}
color="#ccc"
/>
<Text variant="titleMedium" style={styles.emptyText}>
{searchQuery
? `Пользователь @${searchQuery} не найден`
: selectedTab === 'online'
? 'Нет пользователей онлайн'
: 'Нет доступных пользователей'}
</Text>
{searchQuery && (
<Text variant="bodyMedium" style={[styles.emptyText, { marginTop: 8 }]}>
Проверьте правильность никнейма
</Text>
)}
</View>
}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
backgroundColor: '#ffffff',
paddingBottom: 16,
elevation: 2,
},
searchContainer: {
padding: 16,
paddingBottom: 8,
},
searchbar: {
backgroundColor: '#f8f9fa',
borderRadius: 12,
},
searchHint: {
color: '#666',
marginTop: 8,
textAlign: 'center',
},
statsContainer: {
flexDirection: 'row',
justifyContent: 'center',
paddingHorizontal: 16,
paddingTop: 8,
},
statItem: {
alignItems: 'center',
paddingHorizontal: 24,
},
statDivider: {
width: 1,
height: 40,
backgroundColor: '#e0e0e0',
},
statNumber: {
fontWeight: 'bold',
},
statLabel: {
color: '#666',
marginTop: 2,
},
onlineStatContainer: {
flexDirection: 'row',
alignItems: 'center',
},
tabs: {
marginHorizontal: 16,
marginVertical: 16,
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 16,
},
userCard: {
backgroundColor: '#ffffff',
borderRadius: 16,
marginBottom: 12,
},
userCardShadow: {
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 1,
},
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
headerShadow: {
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 4,
},
userContent: {
flexDirection: 'row',
padding: 16,
alignItems: 'center',
},
avatarContainer: {
position: 'relative',
marginRight: 16,
},
avatar: {
elevation: 2,
},
onlineIndicator: {
position: 'absolute',
bottom: 2,
right: 2,
backgroundColor: '#ffffff',
borderRadius: 7,
padding: 1,
},
userInfo: {
flex: 1,
marginRight: 8,
},
userHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
username: {
fontWeight: '600',
color: '#1a1a1a',
},
onlineChip: {
marginLeft: 8,
backgroundColor: '#e8f5e9',
height: 22,
},
onlineChipText: {
fontSize: 11,
color: '#4CAF50',
},
userBio: {
color: '#666',
lineHeight: 20,
},
lastSeen: {
color: '#999',
marginTop: 4,
fontSize: 12,
},
actionContainer: {
justifyContent: 'center',
minWidth: 48,
alignItems: 'center',
},
messageButton: {
margin: 0,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: 100,
},
emptyText: {
color: '#666',
fontSize: 16,
textAlign: 'center',
},
});

View File

@ -1,11 +1,18 @@
<<<<<<< HEAD
import React from 'react';
import { View, StyleSheet, FlatList, TouchableOpacity, Dimensions } from 'react-native';
import { List, Avatar, Text, FAB, Badge, Surface } from 'react-native-paper';
=======
import React, { useState } from 'react';
import { View, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
import { List, Avatar, Text, FAB, Divider, Badge, Searchbar, IconButton, useTheme } from 'react-native-paper';
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
import { useQuery } from '@apollo/client';
import { GET_CONVERSATIONS } from '../graphql/queries';
import { Conversation } from '../types';
import { format, isToday, isYesterday } from 'date-fns';
import { ru } from 'date-fns/locale';
<<<<<<< HEAD
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
FadeInDown,
@ -14,12 +21,20 @@ import Animated, {
} from 'react-native-reanimated';
const { width } = Dimensions.get('window');
=======
import { useAuth } from '../contexts/AuthContext';
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
export const ConversationsScreen = ({ navigation }: any) => {
const [searchQuery, setSearchQuery] = useState('');
const { user } = useAuth();
const theme = useTheme();
const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS, {
pollInterval: 5000,
});
<<<<<<< HEAD
const formatMessageTime = (date: string) => {
const messageDate = new Date(date);
if (isToday(messageDate)) {
@ -33,10 +48,28 @@ export const ConversationsScreen = ({ navigation }: any) => {
const renderConversation = ({ item, index }: { item: Conversation; index: number }) => {
const otherParticipant = item.participants.find(p => p.id !== data?.me?.id);
=======
// Фильтрация чатов по поисковому запросу
const filteredConversations = data?.conversations?.filter((conv: Conversation) => {
if (!searchQuery) return true;
const otherParticipant = conv.participants.find(p => p.id !== user?.id);
const displayName = conv.isGroup ? conv.name : otherParticipant?.username;
return displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
conv.lastMessage?.content.toLowerCase().includes(searchQuery.toLowerCase());
}) || [];
const renderConversation = ({ item }: { item: Conversation }) => {
const otherParticipant = item.participants.find(p => p.id !== user?.id);
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
const displayName = item.isGroup ? item.name : otherParticipant?.username;
const lastMessageTime = item.lastMessage
? formatMessageTime(item.lastMessage.createdAt)
: '';
// Подсчет непрочитанных сообщений (в будущем добавить в GraphQL)
const unreadCount = 0;
return (
<Animated.View
@ -68,6 +101,7 @@ export const ConversationsScreen = ({ navigation }: any) => {
<Badge style={styles.onlineBadge} size={14} />
)}
</View>
<<<<<<< HEAD
<View style={styles.contentContainer}>
<View style={styles.headerRow}>
@ -93,6 +127,17 @@ export const ConversationsScreen = ({ navigation }: any) => {
</Text>
{/* Здесь можно добавить счетчик непрочитанных */}
</View>
=======
)}
right={() => (
<View style={styles.rightContent}>
<Text variant="bodySmall" style={styles.time}>
{lastMessageTime}
</Text>
{unreadCount > 0 && (
<Badge style={styles.unreadBadge}>{unreadCount}</Badge>
)}
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
</View>
</Surface>
</TouchableOpacity>
@ -128,13 +173,28 @@ export const ConversationsScreen = ({ navigation }: any) => {
}
return (
<<<<<<< HEAD
<View style={styles.container}>
<LinearGradient
colors={['#0a0a0a', '#1a1a1a']}
style={StyleSheet.absoluteFillObject}
/>
=======
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
{/* Поисковая строка */}
<View style={[styles.searchContainer, { backgroundColor: theme.colors.surface }]}>
<Searchbar
placeholder="Поиск чатов..."
onChangeText={setSearchQuery}
value={searchQuery}
style={[styles.searchbar, { backgroundColor: theme.colors.surfaceVariant }]}
icon="magnify"
/>
</View>
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
<FlatList
data={data?.conversations || []}
data={filteredConversations}
renderItem={renderConversation}
keyExtractor={(item) => item.id}
onRefresh={refetch}
@ -163,6 +223,7 @@ export const ConversationsScreen = ({ navigation }: any) => {
</Animated.View>
}
/>
<<<<<<< HEAD
<Animated.View
entering={FadeInRight.delay(300).springify()}
>
@ -178,6 +239,13 @@ export const ConversationsScreen = ({ navigation }: any) => {
}}
/>
</Animated.View>
=======
<FAB
icon="plus"
style={styles.fab}
onPress={() => navigation.navigate('Contacts')}
/>
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
</View>
);
};
@ -187,7 +255,21 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#0a0a0a',
},
<<<<<<< HEAD
loadingContainer: {
=======
searchContainer: {
padding: 16,
paddingBottom: 8,
backgroundColor: '#ffffff',
elevation: 2,
},
searchbar: {
elevation: 0,
backgroundColor: '#f5f5f5',
},
centerContainer: {
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
flex: 1,
justifyContent: 'center',
alignItems: 'center',
@ -234,6 +316,7 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.05)',
},
<<<<<<< HEAD
avatarContainer: {
position: 'relative',
marginRight: 12,
@ -251,6 +334,17 @@ const styles = StyleSheet.create({
color: '#ffffff',
fontSize: 20,
fontWeight: '600',
=======
time: {
color: '#666',
fontSize: 12,
marginBottom: 4,
},
unreadBadge: {
backgroundColor: '#2196F3',
color: '#ffffff',
fontSize: 12,
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
},
onlineBadge: {
position: 'absolute',

View File

@ -1,6 +1,11 @@
import React, { useState, useEffect } from 'react';
<<<<<<< HEAD
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions, TouchableOpacity } from 'react-native';
import { TextInput, Button, Text, Headline, HelperText, Surface } from 'react-native-paper';
=======
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView, Dimensions } from 'react-native';
import { TextInput, Button, Text, Headline, HelperText, useTheme } from 'react-native-paper';
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
import { useMutation } from '@apollo/client';
import { LOGIN } from '../graphql/mutations';
import { useAuth } from '../contexts/AuthContext';
@ -28,6 +33,7 @@ export const LoginScreen = ({ navigation }: any) => {
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { login } = useAuth();
const theme = useTheme();
// Анимации
const cardScale = useSharedValue(0.95);
@ -100,6 +106,7 @@ export const LoginScreen = ({ navigation }: any) => {
};
return (
<<<<<<< HEAD
<BackgroundDesign variant="login">
<KeyboardAvoidingView
style={styles.container}
@ -109,6 +116,118 @@ export const LoginScreen = ({ navigation }: any) => {
<Animated.View
style={[styles.content, cardAnimatedStyle]}
entering={FadeInDown.duration(800).springify()}
=======
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: theme.colors.background }]}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<Animated.View style={[styles.content, containerAnimatedStyle]}>
<Animated.View style={[styles.glowContainer, glowContainerStyle]}>
<Headline style={styles.title}>Вход в Prism</Headline>
<Text style={styles.subtitle}>Добро пожаловать обратно</Text>
</Animated.View>
<AnimatedView style={inputStyle1}>
<TextInput
label="Имя пользователя"
value={username}
onChangeText={setUsername}
mode="outlined"
style={styles.input}
autoCapitalize="none"
disabled={loading}
theme={{
colors: {
primary: theme.colors.primary,
placeholder: theme.colors.secondary,
text: theme.colors.onSurface,
background: theme.colors.surface,
outline: theme.colors.outline,
}
}}
onFocus={() => {
inputFocusAnimation1.value = withSpring(1);
}}
onBlur={() => {
inputFocusAnimation1.value = withSpring(0);
}}
/>
</AnimatedView>
<AnimatedView style={inputStyle2}>
<TextInput
label="Пароль"
value={password}
onChangeText={setPassword}
mode="outlined"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
theme={{
colors: {
primary: theme.colors.primary,
placeholder: theme.colors.secondary,
text: theme.colors.onSurface,
background: theme.colors.surface,
outline: theme.colors.outline,
}
}}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
color="#a855f7"
/>
}
onFocus={() => {
inputFocusAnimation2.value = withSpring(1);
}}
onBlur={() => {
inputFocusAnimation2.value = withSpring(0);
}}
/>
</AnimatedView>
{error && (
<HelperText type="error" visible={true} style={styles.errorText}>
{error.message}
</HelperText>
)}
<AnimatedView style={buttonAnimatedStyle}>
<Button
mode="contained"
onPress={handleLogin}
onPressIn={handleButtonPressIn}
onPressOut={handleButtonPressOut}
loading={loading}
disabled={loading || !username || !password}
style={styles.button}
contentStyle={styles.buttonContent}
labelStyle={styles.buttonLabel}
theme={{
colors: {
primary: '#9333ea',
}
}}
>
Войти
</Button>
</AnimatedView>
<Button
mode="text"
onPress={() => navigation.navigate('Register')}
disabled={loading}
style={styles.linkButton}
labelStyle={styles.linkButtonLabel}
theme={{
colors: {
primary: '#a855f7',
}
}}
>>>>>>> a3ad9832ae1663e2a76b50c417d43bcb23a0e03a
>
<Animated.View style={[styles.loginCard, glowStyle]}>
<LinearGradient

View 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. **Светлая тема (переключатель)**

View File

@ -0,0 +1,500 @@
import React, { useState } from 'react';
import { View, StyleSheet, ScrollView, Switch, Alert } from 'react-native';
import { Avatar, Text, Card, List, Button, Divider, useTheme, IconButton, Surface, TextInput, Portal, Modal, ActivityIndicator } from 'react-native-paper';
import { useAuth } from '../contexts/AuthContext';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useMutation } from '@apollo/client';
import { UPDATE_PROFILE, UPDATE_ONLINE_STATUS } from '../graphql/mutations';
export const ProfileScreen = ({ navigation }: any) => {
const { user, logout } = useAuth();
const theme = useTheme();
// Состояния для модальных окон
const [editProfileVisible, setEditProfileVisible] = useState(false);
const [privacyVisible, setPrivacyVisible] = useState(false);
const [notificationsVisible, setNotificationsVisible] = useState(false);
const [deleteAccountVisible, setDeleteAccountVisible] = useState(false);
// Состояния для настроек
const [bio, setBio] = useState(user?.bio || '');
const [showOnlineStatus, setShowOnlineStatus] = useState(true);
const [showLastSeen, setShowLastSeen] = useState(true);
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
const [messageNotifications, setMessageNotifications] = useState(true);
const [soundEnabled, setSoundEnabled] = useState(true);
// Состояние темы (пока только темная)
const [isDarkTheme, setIsDarkTheme] = useState(true);
const [updateProfile, { loading: updatingProfile }] = useMutation(UPDATE_PROFILE);
const [updateOnlineStatus] = useMutation(UPDATE_ONLINE_STATUS);
const handleLogout = async () => {
Alert.alert(
'Выход из аккаунта',
'Вы уверены, что хотите выйти?',
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Выйти',
style: 'destructive',
onPress: async () => {
await logout();
}
}
]
);
};
const handleDeleteAccount = () => {
Alert.alert(
'Удаление аккаунта',
'Это действие необратимо. Все ваши данные будут удалены навсегда.',
[
{ text: 'Отмена', style: 'cancel' },
{
text: 'Удалить',
style: 'destructive',
onPress: async () => {
// TODO: Реализовать удаление аккаунта через API
console.log('Deleting account...');
setDeleteAccountVisible(false);
}
}
]
);
};
const saveProfileChanges = async () => {
try {
await updateProfile({
variables: { bio }
});
setEditProfileVisible(false);
Alert.alert('Успешно', 'Профиль обновлен');
} catch (error) {
Alert.alert('Ошибка', 'Не удалось обновить профиль');
}
};
const savePrivacySettings = async () => {
try {
await updateOnlineStatus({
variables: { isOnline: showOnlineStatus }
});
// Сохраняем локальные настройки
await AsyncStorage.setItem('privacy_settings', JSON.stringify({
showOnlineStatus,
showLastSeen
}));
setPrivacyVisible(false);
Alert.alert('Успешно', 'Настройки приватности сохранены');
} catch (error) {
Alert.alert('Ошибка', 'Не удалось сохранить настройки');
}
};
const saveNotificationSettings = async () => {
await AsyncStorage.setItem('notification_settings', JSON.stringify({
notificationsEnabled,
messageNotifications,
soundEnabled
}));
setNotificationsVisible(false);
Alert.alert('Успешно', 'Настройки уведомлений сохранены');
};
return (
<>
<ScrollView style={[styles.container, { backgroundColor: theme.colors.background }]}>
{/* Профиль пользователя */}
<Surface style={[styles.profileCard, { backgroundColor: theme.colors.surface }]} elevation={2}>
<View style={styles.profileContent}>
<Avatar.Text
size={80}
label={user?.username.charAt(0).toUpperCase() || 'U'}
style={{ backgroundColor: theme.colors.primary }}
/>
<View style={styles.profileInfo}>
<Text variant="headlineSmall" style={{ color: theme.colors.onSurface }}>
@{user?.username}
</Text>
<Text variant="bodyMedium" style={{ color: theme.colors.onSurfaceVariant }}>
{user?.email}
</Text>
{user?.bio && (
<Text variant="bodySmall" style={{ color: theme.colors.onSurfaceVariant, marginTop: 4 }}>
{user.bio}
</Text>
)}
<View style={styles.statusContainer}>
<MaterialCommunityIcons
name="circle"
size={10}
color={user?.isOnline ? '#10b981' : '#6b7280'}
/>
<Text variant="bodySmall" style={{ color: user?.isOnline ? '#10b981' : '#6b7280', marginLeft: 4 }}>
{user?.isOnline ? 'В сети' : 'Не в сети'}
</Text>
</View>
</View>
</View>
</Surface>
{/* Основные настройки */}
<Surface style={[styles.settingsSection, { backgroundColor: theme.colors.surface }]} elevation={1}>
<List.Section>
<List.Subheader style={{ color: theme.colors.primary }}>Основные настройки</List.Subheader>
<List.Item
title="Редактировать профиль"
description="Изменить информацию о себе"
titleStyle={{ color: theme.colors.onSurface }}
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
left={(props) => <List.Icon {...props} icon="account-edit" color={theme.colors.primary} />}
right={(props) => <List.Icon {...props} icon="chevron-right" color={theme.colors.onSurfaceVariant} />}
onPress={() => setEditProfileVisible(true)}
/>
<Divider style={{ backgroundColor: theme.colors.outlineVariant }} />
<List.Item
title="Настройки приватности"
description="Онлайн статус и последнее посещение"
titleStyle={{ color: theme.colors.onSurface }}
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
left={(props) => <List.Icon {...props} icon="shield-lock" color={theme.colors.primary} />}
right={(props) => <List.Icon {...props} icon="chevron-right" color={theme.colors.onSurfaceVariant} />}
onPress={() => setPrivacyVisible(true)}
/>
<Divider style={{ backgroundColor: theme.colors.outlineVariant }} />
<List.Item
title="Уведомления"
description="Push-уведомления и звуки"
titleStyle={{ color: theme.colors.onSurface }}
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
left={(props) => <List.Icon {...props} icon="bell" color={theme.colors.primary} />}
right={(props) => <List.Icon {...props} icon="chevron-right" color={theme.colors.onSurfaceVariant} />}
onPress={() => setNotificationsVisible(true)}
/>
</List.Section>
</Surface>
{/* Внешний вид */}
<Surface style={[styles.settingsSection, { backgroundColor: theme.colors.surface }]} elevation={1}>
<List.Section>
<List.Subheader style={{ color: theme.colors.primary }}>Внешний вид</List.Subheader>
<List.Item
title="Темная тема"
description="Включена по умолчанию"
titleStyle={{ color: theme.colors.onSurface }}
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
left={(props) => <List.Icon {...props} icon="theme-light-dark" color={theme.colors.primary} />}
right={() => (
<Switch
value={isDarkTheme}
onValueChange={setIsDarkTheme}
color={theme.colors.primary}
disabled
/>
)}
/>
</List.Section>
</Surface>
{/* Информация */}
<Surface style={[styles.settingsSection, { backgroundColor: theme.colors.surface }]} elevation={1}>
<List.Section>
<List.Subheader style={{ color: theme.colors.primary }}>Информация</List.Subheader>
<List.Item
title="О приложении"
description="Prism Messenger v1.0.0"
titleStyle={{ color: theme.colors.onSurface }}
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
left={(props) => <List.Icon {...props} icon="information" color={theme.colors.primary} />}
onPress={() => {}}
/>
<Divider style={{ backgroundColor: theme.colors.outlineVariant }} />
<List.Item
title="Условия использования"
titleStyle={{ color: theme.colors.onSurface }}
left={(props) => <List.Icon {...props} icon="file-document" color={theme.colors.primary} />}
onPress={() => {}}
/>
<Divider style={{ backgroundColor: theme.colors.outlineVariant }} />
<List.Item
title="Политика конфиденциальности"
titleStyle={{ color: theme.colors.onSurface }}
left={(props) => <List.Icon {...props} icon="shield-check" color={theme.colors.primary} />}
onPress={() => {}}
/>
</List.Section>
</Surface>
{/* Действия с аккаунтом */}
<Surface style={[styles.settingsSection, { backgroundColor: theme.colors.surface, marginBottom: 32 }]} elevation={1}>
<View style={styles.accountActions}>
<Button
mode="contained"
onPress={handleLogout}
style={[styles.logoutButton, { backgroundColor: theme.colors.error }]}
labelStyle={styles.buttonLabel}
icon="logout"
>
Выйти из аккаунта
</Button>
<Button
mode="outlined"
onPress={() => setDeleteAccountVisible(true)}
style={[styles.deleteButton, { borderColor: theme.colors.error }]}
labelStyle={[styles.buttonLabel, { color: theme.colors.error }]}
icon="account-remove"
>
Удалить аккаунт
</Button>
</View>
</Surface>
</ScrollView>
{/* Модальное окно редактирования профиля */}
<Portal>
<Modal
visible={editProfileVisible}
onDismiss={() => setEditProfileVisible(false)}
contentContainerStyle={[styles.modal, { backgroundColor: theme.colors.surface }]}
>
<Text variant="headlineSmall" style={{ color: theme.colors.onSurface, marginBottom: 16 }}>
Редактировать профиль
</Text>
<TextInput
label="О себе"
value={bio}
onChangeText={setBio}
mode="outlined"
multiline
numberOfLines={3}
style={{ marginBottom: 16 }}
outlineColor={theme.colors.outline}
activeOutlineColor={theme.colors.primary}
textColor={theme.colors.onSurface}
/>
<View style={styles.modalActions}>
<Button
mode="text"
onPress={() => setEditProfileVisible(false)}
textColor={theme.colors.onSurfaceVariant}
>
Отмена
</Button>
<Button
mode="contained"
onPress={saveProfileChanges}
loading={updatingProfile}
disabled={updatingProfile}
>
Сохранить
</Button>
</View>
</Modal>
</Portal>
{/* Модальное окно настроек приватности */}
<Portal>
<Modal
visible={privacyVisible}
onDismiss={() => setPrivacyVisible(false)}
contentContainerStyle={[styles.modal, { backgroundColor: theme.colors.surface }]}
>
<Text variant="headlineSmall" style={{ color: theme.colors.onSurface, marginBottom: 16 }}>
Настройки приватности
</Text>
<List.Item
title="Показывать онлайн статус"
titleStyle={{ color: theme.colors.onSurface }}
description="Другие пользователи видят, когда вы в сети"
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
right={() => (
<Switch
value={showOnlineStatus}
onValueChange={setShowOnlineStatus}
color={theme.colors.primary}
/>
)}
/>
<List.Item
title="Показывать время последнего визита"
titleStyle={{ color: theme.colors.onSurface }}
description="Видно, когда вы последний раз были в сети"
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
right={() => (
<Switch
value={showLastSeen}
onValueChange={setShowLastSeen}
color={theme.colors.primary}
/>
)}
/>
<View style={styles.modalActions}>
<Button
mode="text"
onPress={() => setPrivacyVisible(false)}
textColor={theme.colors.onSurfaceVariant}
>
Отмена
</Button>
<Button
mode="contained"
onPress={savePrivacySettings}
>
Сохранить
</Button>
</View>
</Modal>
</Portal>
{/* Модальное окно настроек уведомлений */}
<Portal>
<Modal
visible={notificationsVisible}
onDismiss={() => setNotificationsVisible(false)}
contentContainerStyle={[styles.modal, { backgroundColor: theme.colors.surface }]}
>
<Text variant="headlineSmall" style={{ color: theme.colors.onSurface, marginBottom: 16 }}>
Настройки уведомлений
</Text>
<List.Item
title="Push-уведомления"
titleStyle={{ color: theme.colors.onSurface }}
description="Получать уведомления о новых сообщениях"
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
right={() => (
<Switch
value={notificationsEnabled}
onValueChange={setNotificationsEnabled}
color={theme.colors.primary}
/>
)}
/>
<List.Item
title="Уведомления о сообщениях"
titleStyle={{ color: theme.colors.onSurface }}
description="Показывать превью сообщений"
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
right={() => (
<Switch
value={messageNotifications}
onValueChange={setMessageNotifications}
color={theme.colors.primary}
disabled={!notificationsEnabled}
/>
)}
/>
<List.Item
title="Звуковые уведомления"
titleStyle={{ color: theme.colors.onSurface }}
description="Воспроизводить звук при новом сообщении"
descriptionStyle={{ color: theme.colors.onSurfaceVariant }}
right={() => (
<Switch
value={soundEnabled}
onValueChange={setSoundEnabled}
color={theme.colors.primary}
disabled={!notificationsEnabled}
/>
)}
/>
<View style={styles.modalActions}>
<Button
mode="text"
onPress={() => setNotificationsVisible(false)}
textColor={theme.colors.onSurfaceVariant}
>
Отмена
</Button>
<Button
mode="contained"
onPress={saveNotificationSettings}
>
Сохранить
</Button>
</View>
</Modal>
</Portal>
</>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
profileCard: {
margin: 16,
padding: 20,
borderRadius: 16,
},
profileContent: {
flexDirection: 'row',
alignItems: 'center',
},
profileInfo: {
marginLeft: 16,
flex: 1,
},
statusContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
},
settingsSection: {
marginHorizontal: 16,
marginBottom: 16,
borderRadius: 16,
overflow: 'hidden',
},
accountActions: {
padding: 16,
gap: 12,
},
logoutButton: {
paddingVertical: 4,
},
deleteButton: {
paddingVertical: 4,
},
buttonLabel: {
fontSize: 16,
fontWeight: '600',
},
modal: {
margin: 20,
padding: 20,
borderRadius: 16,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 16,
gap: 8,
},
});

208
frontend/src/theme/index.ts Normal file
View 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;