Sprint 4: React Native mobile app (iOS+Android)
Screens: - LoginScreen / RegisterScreen (with mandatory consents checkboxes) - MapScreen — MAIN screen after login (Google Maps on Android, Apple Maps on iOS) * shows nearby users within radius * visibility toggle (hide/show myself on map) * radius selector 1/5/10/25/50 km * GPS auto-update with permission flow - ChatListScreen / ChatScreen with WebSocket realtime * long-press message: edit/delete * 'ред.' marker for edited messages * mark as read - ProfileScreen — bio, city, gender, drinks, activities, purposes State: Zustand (auth, map stores) HTTP: axios + JWT interceptor + token refresh WS: socket.io-client Storage: AsyncStorage for tokens Maps: react-native-maps Permissions: react-native-permissions
This commit is contained in:
parent
412a257b71
commit
4bd3ebae99
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
npm-debug.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
24
App.tsx
Normal file
24
App.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { StatusBar } from 'react-native';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
import RootNavigation from '@/navigation';
|
||||||
|
import { Colors } from '@/theme/colors';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { boot } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
boot();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<StatusBar barStyle="dark-content" backgroundColor={Colors.bg} />
|
||||||
|
<RootNavigation />
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
README.md
79
README.md
@ -1,3 +1,78 @@
|
|||||||
# buhapp-mobile
|
# BuhApp Mobile
|
||||||
|
|
||||||
React Native приложение (iOS+Android)
|
React Native приложение для iOS и Android.
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- React Native 0.74+
|
||||||
|
- TypeScript
|
||||||
|
- React Navigation (stack + bottom tabs)
|
||||||
|
- Zustand (state)
|
||||||
|
- Axios (HTTP)
|
||||||
|
- Socket.IO Client (WebSocket)
|
||||||
|
- react-native-maps (карты)
|
||||||
|
- @react-native-async-storage/async-storage
|
||||||
|
- react-native-permissions (location, notifications)
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
buhapp-mobile/
|
||||||
|
├── src/
|
||||||
|
│ ├── api/ — axios client, endpoints
|
||||||
|
│ ├── auth/ — токены, auth store
|
||||||
|
│ ├── screens/ — экраны
|
||||||
|
│ │ ├── auth/ — Welcome, Register, Login
|
||||||
|
│ │ ├── map/ — MapScreen (главный экран)
|
||||||
|
│ │ ├── profile/ — ProfileScreen, EditProfile
|
||||||
|
│ │ ├── chat/ — ChatListScreen, ChatScreen
|
||||||
|
│ │ └── settings/
|
||||||
|
│ ├── components/ — кнопки, формы, элементы
|
||||||
|
│ ├── navigation/ — навигация
|
||||||
|
│ ├── store/ — zustand stores
|
||||||
|
│ ├── ws/ — WebSocket клиент
|
||||||
|
│ ├── theme/ — цвета, типографика
|
||||||
|
│ └── types/ — TypeScript типы
|
||||||
|
├── android/ — Android натив
|
||||||
|
├── ios/ — iOS натив
|
||||||
|
├── App.tsx
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend API
|
||||||
|
|
||||||
|
https://api.buhapp.mygoodservice.ru (или http://localhost:8080 в dev)
|
||||||
|
|
||||||
|
## Запуск (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Установить зависимости
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. iOS
|
||||||
|
npx pod-install
|
||||||
|
npm run ios
|
||||||
|
|
||||||
|
# 3. Android
|
||||||
|
npm run android
|
||||||
|
```
|
||||||
|
|
||||||
|
## Карта (главный экран)
|
||||||
|
|
||||||
|
После онбординга пользователь попадает на карту. На ней:
|
||||||
|
- его собственная точка
|
||||||
|
- другие пользователи (если включена видимость)
|
||||||
|
- кнопка "обновить местоположение"
|
||||||
|
- фильтры (возраст, что пьёт, что делает)
|
||||||
|
|
||||||
|
## Юр. документы
|
||||||
|
|
||||||
|
При регистрации пользователь принимает:
|
||||||
|
- Пользовательское соглашение
|
||||||
|
- Политику конфиденциальности
|
||||||
|
- Отказ от ответственности
|
||||||
|
- Подтверждение 18+
|
||||||
|
|
||||||
|
См. `/buhapp-docs/legal/` (репозиторий buhapp-docs).
|
||||||
|
|||||||
4
app.json
Normal file
4
app.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "buhapp-mobile",
|
||||||
|
"displayName": "BuhApp"
|
||||||
|
}
|
||||||
3
babel.config.js
Normal file
3
babel.config.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: ['module:@react-native/babel-preset'],
|
||||||
|
};
|
||||||
5
index.js
Normal file
5
index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { AppRegistry } from 'react-native';
|
||||||
|
import App from './App';
|
||||||
|
import { name as appName } from './app.json';
|
||||||
|
|
||||||
|
AppRegistry.registerComponent(appName, () => App);
|
||||||
2
metro.config.js
Normal file
2
metro.config.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
|
||||||
|
module.exports = mergeConfig(getDefaultConfig(__dirname), {});
|
||||||
49
package.json
Normal file
49
package.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "buhapp-mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"android": "react-native run-android",
|
||||||
|
"ios": "react-native run-ios",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"start": "react-native start",
|
||||||
|
"test": "jest",
|
||||||
|
"tsc": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "^1.23.1",
|
||||||
|
"@react-navigation/bottom-tabs": "^6.6.1",
|
||||||
|
"@react-navigation/native": "^6.1.18",
|
||||||
|
"@react-navigation/native-stack": "^6.11.0",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-native": "0.74.5",
|
||||||
|
"react-native-gesture-handler": "^2.18.1",
|
||||||
|
"react-native-maps": "^1.18.0",
|
||||||
|
"react-native-permissions": "^4.1.5",
|
||||||
|
"react-native-safe-area-context": "^4.10.9",
|
||||||
|
"react-native-screens": "^3.34.0",
|
||||||
|
"socket.io-client": "^4.7.5",
|
||||||
|
"zustand": "^4.5.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.25.2",
|
||||||
|
"@babel/preset-env": "^7.25.4",
|
||||||
|
"@babel/runtime": "^7.25.6",
|
||||||
|
"@react-native/babel-preset": "0.74.87",
|
||||||
|
"@react-native/eslint-config": "0.74.87",
|
||||||
|
"@react-native/metro-config": "0.74.87",
|
||||||
|
"@react-native/typescript-config": "0.74.87",
|
||||||
|
"@types/react": "^18.2.6",
|
||||||
|
"@types/react-test-renderer": "^18.0.0",
|
||||||
|
"babel-jest": "^29.6.3",
|
||||||
|
"eslint": "^8.19.0",
|
||||||
|
"jest": "^29.6.3",
|
||||||
|
"prettier": "2.8.8",
|
||||||
|
"react-test-renderer": "18.2.0",
|
||||||
|
"typescript": "5.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/api/client.ts
Normal file
200
src/api/client.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import axios, { AxiosError, AxiosInstance } from 'axios';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Config } from '@/config';
|
||||||
|
import type {
|
||||||
|
AuthResponse, MeResponse, RegisterRequest, Prefs, Nearby, ChatListItem, Message,
|
||||||
|
Tokens, User,
|
||||||
|
} from '@/types/api';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'buhapp.tokens';
|
||||||
|
|
||||||
|
let tokens: Tokens | null = null;
|
||||||
|
let memoryTokens: Tokens | null = null;
|
||||||
|
|
||||||
|
export async function loadTokens(): Promise<Tokens | null> {
|
||||||
|
if (memoryTokens) return memoryTokens;
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(TOKEN_KEY);
|
||||||
|
if (raw) {
|
||||||
|
memoryTokens = JSON.parse(raw) as Tokens;
|
||||||
|
return memoryTokens;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('loadTokens error', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTokens(t: Tokens | null) {
|
||||||
|
memoryTokens = t;
|
||||||
|
if (t) {
|
||||||
|
await AsyncStorage.setItem(TOKEN_KEY, JSON.stringify(t));
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return memoryTokens?.access ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api: AxiosInstance = axios.create({
|
||||||
|
baseURL: Config.API_URL,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use(async (cfg) => {
|
||||||
|
if (!memoryTokens) {
|
||||||
|
await loadTokens();
|
||||||
|
}
|
||||||
|
if (memoryTokens?.access) {
|
||||||
|
cfg.headers.Authorization = `Bearer ${memoryTokens.access}`;
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(r) => r,
|
||||||
|
async (err: AxiosError) => {
|
||||||
|
if (err.response?.status === 401 && memoryTokens?.refresh) {
|
||||||
|
// попробуем refresh
|
||||||
|
try {
|
||||||
|
const r = await axios.post(`${Config.API_URL}/api/v1/auth/refresh`, {
|
||||||
|
refresh: memoryTokens.refresh,
|
||||||
|
});
|
||||||
|
const newT = r.data as Tokens;
|
||||||
|
await setTokens(newT);
|
||||||
|
if (err.config) {
|
||||||
|
err.config.headers.Authorization = `Bearer ${newT.access}`;
|
||||||
|
return axios(err.config);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await setTokens(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- API ----------
|
||||||
|
|
||||||
|
export const AuthApi = {
|
||||||
|
async register(req: RegisterRequest): Promise<AuthResponse> {
|
||||||
|
const r = await api.post<AuthResponse>('/api/v1/auth/register', req);
|
||||||
|
await setTokens(r.data.tokens);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async login(email: string, password: string): Promise<AuthResponse> {
|
||||||
|
const r = await api.post<AuthResponse>('/api/v1/auth/login', { email, password });
|
||||||
|
await setTokens(r.data.tokens);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async me(): Promise<MeResponse> {
|
||||||
|
const r = await api.get<MeResponse>('/api/v1/me');
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async updateMe(p: Partial<User>): Promise<User> {
|
||||||
|
const r = await api.put<User>('/api/v1/me', p);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async getPrefs(): Promise<Prefs> {
|
||||||
|
const r = await api.get<Prefs>('/api/v1/me/prefs');
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async updatePrefs(p: Partial<Prefs>): Promise<Prefs> {
|
||||||
|
const r = await api.put<Prefs>('/api/v1/me/prefs', p);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async updateLocation(lat: number, lng: number, visible: boolean) {
|
||||||
|
const r = await api.put('/api/v1/me/location', { lat, lng, visible });
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async setVisibility(visible: boolean) {
|
||||||
|
const r = await api.put('/api/v1/me/visibility', { visible });
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async searchNearby(lat: number, lng: number, radiusKm: number): Promise<{ count: number; results: Nearby[] }> {
|
||||||
|
const r = await api.get('/api/v1/search/nearby', { params: { lat, lng, radius: radiusKm } });
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async getUser(id: string) {
|
||||||
|
const r = await api.get(`/api/v1/users/${id}`);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
await setTokens(null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatApi = {
|
||||||
|
async ensureChat(otherId: string) {
|
||||||
|
const r = await api.post('/api/v1/chats', { other_id: otherId });
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async listChats(): Promise<{ chats: ChatListItem[]; count: number }> {
|
||||||
|
const r = await api.get('/api/v1/chats');
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async listMessages(chatId: string) {
|
||||||
|
const r = await api.get(`/api/v1/chats/${chatId}/messages`);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async sendMessage(chatId: string, body?: string, photoUrl?: string) {
|
||||||
|
const r = await api.post(`/api/v1/chats/${chatId}/messages`, { body, photo_url: photoUrl });
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async editMessage(msgId: string, body: string) {
|
||||||
|
const r = await api.put(`/api/v1/messages/${msgId}`, { body });
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async deleteMessage(msgId: string) {
|
||||||
|
const r = await api.delete(`/api/v1/messages/${msgId}`);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async markRead(chatId: string) {
|
||||||
|
const r = await api.put(`/api/v1/chats/${chatId}/read`, {});
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async block(userId: string) {
|
||||||
|
const r = await api.post('/api/v1/blocks', { user_id: userId });
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async unblock(userId: string) {
|
||||||
|
const r = await api.delete(`/api/v1/blocks/${userId}`);
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async report(targetType: 'user' | 'message' | 'chat', targetId: string, reason: string) {
|
||||||
|
const r = await api.post('/api/v1/reports', { target_type: targetType, target_id: targetId, reason });
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- WS ----------
|
||||||
|
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
|
export async function connectWS() {
|
||||||
|
if (socket?.connected) return socket;
|
||||||
|
const t = await loadTokens();
|
||||||
|
if (!t) return null;
|
||||||
|
socket = io(Config.WS_URL, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
auth: { token: t.access },
|
||||||
|
query: { token: t.access },
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
});
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSocket(): Socket | null {
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnectWS() {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/config.ts
Normal file
24
src/config.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export const Config = {
|
||||||
|
// Заменить на свой API URL при деплое
|
||||||
|
API_URL: __DEV__ ? 'http://localhost:8080' : 'https://api.buhapp.mygoodservice.ru',
|
||||||
|
WS_URL: __DEV__ ? 'ws://localhost:8080' : 'wss://api.buhapp.mygoodservice.ru',
|
||||||
|
|
||||||
|
// карта
|
||||||
|
DEFAULT_RADIUS_KM: 5,
|
||||||
|
LOCATION_INTERVAL_MS: 30000,
|
||||||
|
LOCATION_DISTANCE_M: 50,
|
||||||
|
|
||||||
|
// валидация
|
||||||
|
MIN_PASSWORD_LENGTH: 8,
|
||||||
|
MIN_AGE: 18,
|
||||||
|
|
||||||
|
// прочее
|
||||||
|
APP_NAME: 'BuhApp',
|
||||||
|
VERSION: '0.1.0',
|
||||||
|
|
||||||
|
// platforms
|
||||||
|
IS_IOS: Platform.OS === 'ios',
|
||||||
|
IS_ANDROID: Platform.OS === 'android',
|
||||||
|
};
|
||||||
78
src/navigation/index.tsx
Normal file
78
src/navigation/index.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
import { Colors, FontSizes } from '@/theme/colors';
|
||||||
|
|
||||||
|
import LoginScreen from '@/screens/auth/LoginScreen';
|
||||||
|
import RegisterScreen from '@/screens/auth/RegisterScreen';
|
||||||
|
import MapScreen from '@/screens/map/MapScreen';
|
||||||
|
import ChatListScreen from '@/screens/chat/ChatListScreen';
|
||||||
|
import ChatScreen from '@/screens/chat/ChatScreen';
|
||||||
|
import ProfileScreen from '@/screens/profile/ProfileScreen';
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator();
|
||||||
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
|
function MainTabs() {
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: Colors.bg },
|
||||||
|
headerTitleStyle: { color: Colors.text, fontSize: FontSizes.l, fontWeight: '700' },
|
||||||
|
tabBarActiveTintColor: Colors.primary,
|
||||||
|
tabBarInactiveTintColor: Colors.textDim,
|
||||||
|
tabBarStyle: { backgroundColor: Colors.bg, borderTopColor: Colors.border },
|
||||||
|
}}>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Map"
|
||||||
|
component={MapScreen}
|
||||||
|
options={{
|
||||||
|
title: 'Карта',
|
||||||
|
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>🗺️</Text>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Chats"
|
||||||
|
component={ChatListScreen}
|
||||||
|
options={{
|
||||||
|
title: 'Чаты',
|
||||||
|
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>💬</Text>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Profile"
|
||||||
|
component={ProfileScreen}
|
||||||
|
options={{
|
||||||
|
title: 'Профиль',
|
||||||
|
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>👤</Text>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootNavigation() {
|
||||||
|
const { user, booted } = useAuth();
|
||||||
|
if (!booted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationContainer>
|
||||||
|
<Stack.Navigator>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Stack.Screen name="Main" component={MainTabs} options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="Chat" component={ChatScreen} options={({ route }: any) => ({ title: route.params?.otherName ?? 'Чат' })} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack.Screen name="Login" component={LoginScreen} options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="Register" component={RegisterScreen} options={{ headerShown: false }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack.Navigator>
|
||||||
|
</NavigationContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/screens/auth/LoginScreen.tsx
Normal file
77
src/screens/auth/LoginScreen.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const nav = useNavigation<any>();
|
||||||
|
const { login, loading, error } = useAuth();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await login(email.trim().toLowerCase(), password);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка', e?.response?.data?.error ?? 'не удалось войти');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Text style={styles.title}>Вход</Text>
|
||||||
|
<Text style={styles.subtitle}>BuhApp</Text>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Email</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
placeholderTextColor={Colors.textDim}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Пароль</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
placeholder="Ваш пароль"
|
||||||
|
placeholderTextColor={Colors.textDim}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, loading && { opacity: 0.5 }]}
|
||||||
|
disabled={loading}
|
||||||
|
onPress={onSubmit}>
|
||||||
|
<Text style={styles.btnText}>{loading ? 'Вход...' : 'Войти'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={() => nav.navigate('Register')} style={{ marginTop: Spacing.l }}>
|
||||||
|
<Text style={styles.link}>Создать аккаунт</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: Colors.bg, padding: Spacing.l, paddingTop: Spacing.xxl },
|
||||||
|
title: { fontSize: FontSizes.xxl, fontWeight: '700', color: Colors.text },
|
||||||
|
subtitle: { fontSize: FontSizes.m, color: Colors.textDim, marginBottom: Spacing.xl },
|
||||||
|
label: { fontSize: FontSizes.s, color: Colors.textDim, marginBottom: Spacing.xs, marginTop: Spacing.m },
|
||||||
|
input: {
|
||||||
|
borderWidth: 1, borderColor: Colors.border, borderRadius: Radius.m,
|
||||||
|
paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
|
||||||
|
fontSize: FontSizes.m, color: Colors.text, backgroundColor: Colors.bg,
|
||||||
|
},
|
||||||
|
btn: { backgroundColor: Colors.primary, paddingVertical: Spacing.m, borderRadius: Radius.m, alignItems: 'center', marginTop: Spacing.l },
|
||||||
|
btnText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '600' },
|
||||||
|
link: { color: Colors.secondary, textAlign: 'center', fontSize: FontSizes.m },
|
||||||
|
error: { color: Colors.danger, fontSize: FontSizes.s, marginTop: Spacing.s },
|
||||||
|
});
|
||||||
179
src/screens/auth/RegisterScreen.tsx
Normal file
179
src/screens/auth/RegisterScreen.tsx
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity, ScrollView, Switch, StyleSheet, Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
|
||||||
|
import { Config } from '@/config';
|
||||||
|
import type { Gender } from '@/types/api';
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const nav = useNavigation<any>();
|
||||||
|
const { register, loading, error } = useAuth();
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [birthdate, setBirthdate] = useState('');
|
||||||
|
const [gender, setGender] = useState<Gender | ''>('');
|
||||||
|
const [city, setCity] = useState('');
|
||||||
|
|
||||||
|
const [cAdult, setCAdult] = useState(false);
|
||||||
|
const [cTerms, setCTerms] = useState(false);
|
||||||
|
const [cPrivacy, setCPrivacy] = useState(false);
|
||||||
|
const [cDisclaimer, setCDisclaimer] = useState(false);
|
||||||
|
|
||||||
|
const allConsents = cAdult && cTerms && cPrivacy && cDisclaimer;
|
||||||
|
const canSubmit =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
(email.trim() || phone.trim()) &&
|
||||||
|
password.length >= Config.MIN_PASSWORD_LENGTH &&
|
||||||
|
allConsents &&
|
||||||
|
!loading;
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!allConsents) {
|
||||||
|
Alert.alert('Согласия', 'Подтвердите все 4 пункта: 18+, соглашение, политика, отказ');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
phone: phone.trim() || undefined,
|
||||||
|
password,
|
||||||
|
name: name.trim(),
|
||||||
|
birthdate: birthdate.trim() || undefined,
|
||||||
|
gender: gender || undefined,
|
||||||
|
city: city.trim() || undefined,
|
||||||
|
consents: {
|
||||||
|
adult: cAdult,
|
||||||
|
terms: cTerms,
|
||||||
|
privacy: cPrivacy,
|
||||||
|
disclaimer: cDisclaimer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка', e?.response?.data?.error ?? 'не удалось зарегистрироваться');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.title}>Регистрация</Text>
|
||||||
|
<Text style={styles.subtitle}>BuhApp — для совершеннолетних</Text>
|
||||||
|
|
||||||
|
<Field label="Имя" value={name} onChange={setName} placeholder="Как тебя зовут" />
|
||||||
|
<Field label="Email" value={email} onChange={setEmail} placeholder="you@example.com" keyboardType="email-address" autoCapitalize="none" />
|
||||||
|
<Field label="Телефон (опционально)" value={phone} onChange={setPhone} placeholder="+79991234567" keyboardType="phone-pad" />
|
||||||
|
<Field label="Пароль" value={password} onChange={setPassword} placeholder={`минимум ${Config.MIN_PASSWORD_LENGTH} символов`} secureTextEntry />
|
||||||
|
<Field label="Дата рождения (ГГГГ-ММ-ДД)" value={birthdate} onChange={setBirthdate} placeholder="1995-06-15" autoCapitalize="none" />
|
||||||
|
<Field label="Город" value={city} onChange={setCity} placeholder="Москва" />
|
||||||
|
|
||||||
|
<Text style={styles.label}>Пол</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
{(['m','f','o'] as Gender[]).map((g) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={g}
|
||||||
|
style={[styles.chip, gender === g && styles.chipActive]}
|
||||||
|
onPress={() => setGender(g)}>
|
||||||
|
<Text style={[styles.chipText, gender === g && styles.chipTextActive]}>
|
||||||
|
{g === 'm' ? 'Мужской' : g === 'f' ? 'Женский' : 'Другое'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.label, { marginTop: Spacing.l }]}>Согласия (обязательно)</Text>
|
||||||
|
<ConsentRow
|
||||||
|
label={`Мне исполнилось 18 лет`}
|
||||||
|
value={cAdult} onChange={setCAdult}
|
||||||
|
/>
|
||||||
|
<ConsentRow
|
||||||
|
label={`Пользовательское соглашение (версия 1.0)`}
|
||||||
|
value={cTerms} onChange={setCTerms}
|
||||||
|
/>
|
||||||
|
<ConsentRow
|
||||||
|
label={`Политика конфиденциальности (152-ФЗ, версия 1.0)`}
|
||||||
|
value={cPrivacy} onChange={setCPrivacy}
|
||||||
|
/>
|
||||||
|
<ConsentRow
|
||||||
|
label={`Отказ от ответственности (версия 1.0)`}
|
||||||
|
value={cDisclaimer} onChange={setCDisclaimer}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btn, !canSubmit && styles.btnDisabled]}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onPress={onSubmit}>
|
||||||
|
<Text style={styles.btnText}>{loading ? 'Отправка...' : 'Создать аккаунт'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={() => nav.navigate('Login')} style={{ marginTop: Spacing.l }}>
|
||||||
|
<Text style={styles.link}>У меня уже есть аккаунт</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, ...props }: any) {
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: Spacing.m }}>
|
||||||
|
<Text style={styles.label}>{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholderTextColor={Colors.textDim}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.consent}>
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onChange}
|
||||||
|
trackColor={{ true: Colors.primary }}
|
||||||
|
thumbColor={value ? Colors.primaryDark : '#f4f3f4'}
|
||||||
|
/>
|
||||||
|
<Text style={styles.consentText}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: Colors.bg },
|
||||||
|
content: { padding: Spacing.l, paddingTop: Spacing.xxl },
|
||||||
|
title: { fontSize: FontSizes.xxl, fontWeight: '700', color: Colors.text },
|
||||||
|
subtitle: { fontSize: FontSizes.m, color: Colors.textDim, marginBottom: Spacing.l },
|
||||||
|
label: { fontSize: FontSizes.s, color: Colors.textDim, marginBottom: Spacing.xs },
|
||||||
|
input: {
|
||||||
|
borderWidth: 1, borderColor: Colors.border, borderRadius: Radius.m,
|
||||||
|
paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
|
||||||
|
fontSize: FontSizes.m, color: Colors.text, backgroundColor: Colors.bg,
|
||||||
|
},
|
||||||
|
row: { flexDirection: 'row', gap: Spacing.s as any },
|
||||||
|
chip: {
|
||||||
|
paddingVertical: Spacing.s, paddingHorizontal: Spacing.m,
|
||||||
|
borderRadius: Radius.full, borderWidth: 1, borderColor: Colors.border,
|
||||||
|
marginRight: Spacing.s,
|
||||||
|
},
|
||||||
|
chipActive: { backgroundColor: Colors.primary, borderColor: Colors.primary },
|
||||||
|
chipText: { color: Colors.text, fontSize: FontSizes.s },
|
||||||
|
chipTextActive: { color: Colors.textInverse, fontWeight: '600' },
|
||||||
|
consent: { flexDirection: 'row', alignItems: 'center', marginBottom: Spacing.s, gap: Spacing.s as any },
|
||||||
|
consentText: { flex: 1, fontSize: FontSizes.s, color: Colors.text },
|
||||||
|
btn: {
|
||||||
|
backgroundColor: Colors.primary, paddingVertical: Spacing.m, borderRadius: Radius.m,
|
||||||
|
alignItems: 'center', marginTop: Spacing.l,
|
||||||
|
},
|
||||||
|
btnDisabled: { opacity: 0.5 },
|
||||||
|
btnText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '600' },
|
||||||
|
link: { color: Colors.secondary, textAlign: 'center', fontSize: FontSizes.m },
|
||||||
|
error: { color: Colors.danger, fontSize: FontSizes.s, marginTop: Spacing.s },
|
||||||
|
});
|
||||||
96
src/screens/chat/ChatListScreen.tsx
Normal file
96
src/screens/chat/ChatListScreen.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Image } from 'react-native';
|
||||||
|
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { ChatApi } from '@/api/client';
|
||||||
|
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
|
||||||
|
import type { ChatListItem } from '@/types/api';
|
||||||
|
|
||||||
|
export default function ChatListScreen() {
|
||||||
|
const nav = useNavigation<any>();
|
||||||
|
const [items, setItems] = useState<ChatListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await ChatApi.listChats();
|
||||||
|
setItems(r.chats);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(useCallback(() => { load(); }, [load]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(c) => c.chat.id}
|
||||||
|
refreshing={loading}
|
||||||
|
onRefresh={load}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={styles.empty}>Нет чатов. Найди кого-нибудь на карте!</Text>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.row}
|
||||||
|
onPress={() => nav.navigate('Chat', { chatId: item.chat.id, otherId: item.other_id, otherName: item.other_name })}>
|
||||||
|
{item.other_photo ? (
|
||||||
|
<Image source={{ uri: item.other_photo }} style={styles.avatar} />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||||
|
<Text style={styles.avatarText}>{item.other_name?.[0] ?? '?'}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.name}>{item.other_name}</Text>
|
||||||
|
<Text style={styles.time}>{formatTime(item.last_msg_at)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.lastRow}>
|
||||||
|
<Text style={styles.last} numberOfLines={1}>
|
||||||
|
{item.last_message || 'Нет сообщений'}
|
||||||
|
</Text>
|
||||||
|
{item.unread_count > 0 && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{item.unread_count}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
if (d.toDateString() === now.toDateString()) {
|
||||||
|
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: Colors.bg },
|
||||||
|
empty: { textAlign: 'center', marginTop: 50, color: Colors.textDim, fontSize: FontSizes.m, paddingHorizontal: Spacing.l },
|
||||||
|
row: { flexDirection: 'row', padding: Spacing.m, borderBottomWidth: 1, borderBottomColor: Colors.border, alignItems: 'center' },
|
||||||
|
avatar: { width: 48, height: 48, borderRadius: 24, marginRight: Spacing.m },
|
||||||
|
avatarPlaceholder: { backgroundColor: Colors.primary, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
avatarText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '700' },
|
||||||
|
body: { flex: 1 },
|
||||||
|
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 },
|
||||||
|
name: { fontSize: FontSizes.m, fontWeight: '600', color: Colors.text },
|
||||||
|
time: { fontSize: FontSizes.xs, color: Colors.textDim },
|
||||||
|
lastRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
|
last: { fontSize: FontSizes.s, color: Colors.textDim, flex: 1, marginRight: Spacing.s },
|
||||||
|
badge: { backgroundColor: Colors.primary, borderRadius: Radius.full, minWidth: 20, height: 20, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 6 },
|
||||||
|
badgeText: { color: Colors.textInverse, fontSize: FontSizes.xs, fontWeight: '700' },
|
||||||
|
});
|
||||||
208
src/screens/chat/ChatScreen.tsx
Normal file
208
src/screens/chat/ChatScreen.tsx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity, FlatList, StyleSheet, KeyboardAvoidingView,
|
||||||
|
Platform, Alert, Modal, Pressable,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||||
|
import { ChatApi, getSocket } from '@/api/client';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
|
||||||
|
import type { Message } from '@/types/api';
|
||||||
|
|
||||||
|
export default function ChatScreen() {
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const nav = useNavigation<any>();
|
||||||
|
const { chatId, otherName } = route.params;
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [msgs, setMsgs] = useState<Message[]>([]);
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [editing, setEditing] = useState<Message | null>(null);
|
||||||
|
const listRef = useRef<FlatList>(null);
|
||||||
|
|
||||||
|
useEffect(() => { load(); markRead(); setupWS(); }, [chatId]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const r = await ChatApi.listMessages(chatId);
|
||||||
|
setMsgs(r.messages);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markRead = () => ChatApi.markRead(chatId).catch(() => {});
|
||||||
|
|
||||||
|
const setupWS = () => {
|
||||||
|
const sock = getSocket();
|
||||||
|
if (!sock) return;
|
||||||
|
|
||||||
|
const onMessage = (data: any) => {
|
||||||
|
if (data.type === 'message' && data.payload?.chat_id === chatId) {
|
||||||
|
setMsgs((prev) => {
|
||||||
|
if (prev.find((m) => m.id === data.payload.id)) return prev;
|
||||||
|
return [...prev, data.payload];
|
||||||
|
});
|
||||||
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100);
|
||||||
|
markRead();
|
||||||
|
}
|
||||||
|
if (data.type === 'message_edited' && data.payload?.chat_id === chatId) {
|
||||||
|
setMsgs((prev) => prev.map((m) =>
|
||||||
|
m.id === data.payload.id
|
||||||
|
? { ...m, body: data.payload.body, edited: data.payload.edited, updated_at: data.payload.updated_at }
|
||||||
|
: m
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sock.on('message', onMessage);
|
||||||
|
sock.on('message_edited', onMessage);
|
||||||
|
return () => {
|
||||||
|
sock.off('message', onMessage);
|
||||||
|
sock.off('message_edited', onMessage);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const body = text.trim();
|
||||||
|
if (!body) return;
|
||||||
|
if (editing) {
|
||||||
|
try {
|
||||||
|
await ChatApi.editMessage(editing.id, body);
|
||||||
|
setMsgs((prev) => prev.map((m) => m.id === editing.id ? { ...m, body, edited: true, updated_at: new Date().toISOString() } : m));
|
||||||
|
setEditing(null);
|
||||||
|
setText('');
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка', e?.response?.data?.error ?? 'edit failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const m = await ChatApi.sendMessage(chatId, body);
|
||||||
|
setMsgs((prev) => [...prev, m]);
|
||||||
|
setText('');
|
||||||
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100);
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка', e?.response?.data?.error ?? 'send failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (m: Message) => {
|
||||||
|
setEditing(m);
|
||||||
|
setText(m.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMsg = (m: Message) => {
|
||||||
|
Alert.alert('Удалить', 'Удалить это сообщение?', [
|
||||||
|
{ text: 'Отмена', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Удалить', style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await ChatApi.deleteMessage(m.id);
|
||||||
|
setMsgs((prev) => prev.filter((x) => x.id !== m.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка', e?.response?.data?.error ?? 'delete failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.root}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
keyboardVerticalOffset={80}>
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={msgs}
|
||||||
|
keyExtractor={(m) => m.id}
|
||||||
|
contentContainerStyle={{ padding: Spacing.m }}
|
||||||
|
renderItem={({ item, index }) => {
|
||||||
|
const mine = item.sender_id === user?.id;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onLongPress={() => mine && (startEdit(item), Alert.alert('Действие', 'Удалить?', [
|
||||||
|
{ text: 'Редактировать', onPress: () => startEdit(item) },
|
||||||
|
{ text: 'Удалить', style: 'destructive', onPress: () => deleteMsg(item) },
|
||||||
|
{ text: 'Отмена', style: 'cancel' },
|
||||||
|
]))}
|
||||||
|
style={[styles.bubble, mine ? styles.bubbleMine : styles.bubbleTheirs]}>
|
||||||
|
<Text style={[styles.bubbleText, mine && { color: Colors.textInverse }]}>
|
||||||
|
{item.body}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<Text style={[styles.meta, mine && { color: 'rgba(255,255,255,0.7)' }]}>
|
||||||
|
{new Date(item.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
{item.edited && (
|
||||||
|
<Text style={[styles.edited, mine && { color: 'rgba(255,255,255,0.7)' }]}>ред.</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<View style={styles.editBar}>
|
||||||
|
<Text style={styles.editBarText}>Редактирование сообщения</Text>
|
||||||
|
<TouchableOpacity onPress={cancelEdit}>
|
||||||
|
<Text style={styles.editBarCancel}>Отмена</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
placeholder="Сообщение..."
|
||||||
|
placeholderTextColor={Colors.textDim}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.sendBtn, !text.trim() && { opacity: 0.4 }]}
|
||||||
|
disabled={!text.trim()}
|
||||||
|
onPress={send}>
|
||||||
|
<Text style={styles.sendText}>{editing ? '✓' : '➤'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: Colors.bg },
|
||||||
|
bubble: { maxWidth: '75%', borderRadius: Radius.l, padding: Spacing.s, marginBottom: Spacing.s },
|
||||||
|
bubbleMine: { alignSelf: 'flex-end', backgroundColor: Colors.primary, borderBottomRightRadius: 4 },
|
||||||
|
bubbleTheirs: { alignSelf: 'flex-start', backgroundColor: Colors.surface, borderBottomLeftRadius: 4 },
|
||||||
|
bubbleText: { fontSize: FontSizes.m, color: Colors.text },
|
||||||
|
metaRow: { flexDirection: 'row', alignItems: 'center', marginTop: 4, gap: 6 as any },
|
||||||
|
meta: { fontSize: FontSizes.xs, color: Colors.textDim },
|
||||||
|
edited: { fontSize: FontSizes.xs, color: Colors.textDim, fontStyle: 'italic' },
|
||||||
|
inputRow: {
|
||||||
|
flexDirection: 'row', padding: Spacing.s, backgroundColor: Colors.surface,
|
||||||
|
borderTopWidth: 1, borderTopColor: Colors.border, alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1, minHeight: 40, maxHeight: 100, backgroundColor: Colors.bg,
|
||||||
|
borderRadius: Radius.l, paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
|
||||||
|
fontSize: FontSizes.m, color: Colors.text,
|
||||||
|
},
|
||||||
|
sendBtn: { backgroundColor: Colors.primary, width: 40, height: 40, borderRadius: 20, justifyContent: 'center', alignItems: 'center', marginLeft: Spacing.s },
|
||||||
|
sendText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '700' },
|
||||||
|
editBar: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: Spacing.s, backgroundColor: Colors.surface, borderTopWidth: 1, borderTopColor: Colors.border,
|
||||||
|
},
|
||||||
|
editBarText: { color: Colors.text, fontSize: FontSizes.s, fontStyle: 'italic' },
|
||||||
|
editBarCancel: { color: Colors.danger, fontSize: FontSizes.s, fontWeight: '600' },
|
||||||
|
});
|
||||||
161
src/screens/map/MapScreen.tsx
Normal file
161
src/screens/map/MapScreen.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, Platform } from 'react-native';
|
||||||
|
import MapView, { Marker, Circle, PROVIDER_GOOGLE, PROVIDER_DEFAULT } from 'react-native-maps';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
import { useMap } from '@/store/map';
|
||||||
|
import { AuthApi } from '@/api/client';
|
||||||
|
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
|
||||||
|
import { requestLocationPermission, getCurrentPosition } from '@/utils/permissions';
|
||||||
|
import { Config } from '@/config';
|
||||||
|
|
||||||
|
export default function MapScreen() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { myLat, myLng, nearby, visible, radiusKm, setMyLocation, setVisible, setRadius, refresh, loading } = useMap();
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const ok = await requestLocationPermission();
|
||||||
|
if (!ok) {
|
||||||
|
Alert.alert('Геолокация', 'Нужен доступ к местоположению, чтобы видеть людей рядом');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pos = await getCurrentPosition();
|
||||||
|
const lat = pos.coords.latitude;
|
||||||
|
const lng = pos.coords.longitude;
|
||||||
|
setMyLocation(lat, lng);
|
||||||
|
await AuthApi.updateLocation(lat, lng, visible);
|
||||||
|
await refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка', e?.message ?? 'не удалось получить координаты');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (myLat != null && myLng != null) {
|
||||||
|
AuthApi.updateLocation(myLat, myLng, visible).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const center = myLat != null && myLng != null
|
||||||
|
? { latitude: myLat, longitude: myLng, latitudeDelta: 0.05, longitudeDelta: 0.05 }
|
||||||
|
: { latitude: 55.7558, longitude: 37.6173, latitudeDelta: 0.05, longitudeDelta: 0.05 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<MapView
|
||||||
|
provider={Platform.OS === 'android' ? PROVIDER_GOOGLE : PROVIDER_DEFAULT}
|
||||||
|
style={styles.map}
|
||||||
|
initialRegion={center}
|
||||||
|
region={center}
|
||||||
|
showsUserLocation
|
||||||
|
showsMyLocationButton>
|
||||||
|
{myLat != null && myLng != null && (
|
||||||
|
<Circle
|
||||||
|
center={{ latitude: myLat, longitude: myLng }}
|
||||||
|
radius={radiusKm * 1000}
|
||||||
|
strokeColor="rgba(255,107,53,0.4)"
|
||||||
|
fillColor="rgba(255,107,53,0.1)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{nearby.map((n) => (
|
||||||
|
<Marker
|
||||||
|
key={n.user_id}
|
||||||
|
coordinate={{ latitude: n.lat, longitude: n.lng }}
|
||||||
|
title={n.name}
|
||||||
|
description={`${(n.distance_m / 1000).toFixed(1)} км`}>
|
||||||
|
<View style={styles.marker}>
|
||||||
|
<Text style={styles.markerText}>{n.name?.[0] ?? '?'}</Text>
|
||||||
|
</View>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapView>
|
||||||
|
|
||||||
|
{/* Верх: имя пользователя + кнопка "обновить" */}
|
||||||
|
<View style={styles.topBar}>
|
||||||
|
<Text style={styles.welcome}>Привет, {user?.name} 👋</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.refreshBtn}
|
||||||
|
disabled={loading}
|
||||||
|
onPress={async () => {
|
||||||
|
try {
|
||||||
|
const pos = await getCurrentPosition();
|
||||||
|
setMyLocation(pos.coords.latitude, pos.coords.longitude);
|
||||||
|
await AuthApi.updateLocation(pos.coords.latitude, pos.coords.longitude, visible);
|
||||||
|
await refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка', e?.message);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Text style={styles.refreshText}>{loading ? '...' : '⟳'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Низ: фильтр + видимость */}
|
||||||
|
<View style={styles.bottomBar}>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.label}>Я на карте</Text>
|
||||||
|
<Switch
|
||||||
|
value={visible}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setVisible(v);
|
||||||
|
AuthApi.setVisibility(v).catch(() => {});
|
||||||
|
}}
|
||||||
|
trackColor={{ true: Colors.primary }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.label}>Радиус: {radiusKm} км</Text>
|
||||||
|
<View style={styles.radiusBtns}>
|
||||||
|
{[1, 5, 10, 25, 50].map((r) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={r}
|
||||||
|
onPress={() => { setRadius(r); refresh(); }}
|
||||||
|
style={[styles.radiusBtn, radiusKm === r && styles.radiusBtnActive]}>
|
||||||
|
<Text style={[styles.radiusBtnText, radiusKm === r && styles.radiusBtnTextActive]}>{r}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.count}>{nearby.length} чел. рядом</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1 },
|
||||||
|
map: { flex: 1 },
|
||||||
|
topBar: {
|
||||||
|
position: 'absolute', top: Spacing.l, left: Spacing.l, right: Spacing.l,
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.95)', borderRadius: Radius.l,
|
||||||
|
paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
|
||||||
|
shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4, elevation: 3,
|
||||||
|
},
|
||||||
|
welcome: { fontSize: FontSizes.m, fontWeight: '600', color: Colors.text, flex: 1 },
|
||||||
|
refreshBtn: { backgroundColor: Colors.primary, width: 36, height: 36, borderRadius: 18, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
refreshText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '700' },
|
||||||
|
bottomBar: {
|
||||||
|
position: 'absolute', bottom: Spacing.l, left: Spacing.l, right: Spacing.l,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.95)', borderRadius: Radius.l,
|
||||||
|
padding: Spacing.m, gap: Spacing.s as any,
|
||||||
|
shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4, elevation: 3,
|
||||||
|
},
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
label: { fontSize: FontSizes.s, color: Colors.text, fontWeight: '500' },
|
||||||
|
radiusBtns: { flexDirection: 'row', gap: Spacing.xs as any },
|
||||||
|
radiusBtn: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: Radius.full, backgroundColor: Colors.surface, marginLeft: 4 },
|
||||||
|
radiusBtnActive: { backgroundColor: Colors.primary },
|
||||||
|
radiusBtnText: { color: Colors.text, fontSize: FontSizes.s },
|
||||||
|
radiusBtnTextActive: { color: Colors.textInverse, fontWeight: '600' },
|
||||||
|
count: { fontSize: FontSizes.s, color: Colors.textDim, marginTop: 4 },
|
||||||
|
marker: {
|
||||||
|
width: 36, height: 36, borderRadius: 18, backgroundColor: Colors.primary,
|
||||||
|
justifyContent: 'center', alignItems: 'center',
|
||||||
|
borderWidth: 2, borderColor: '#fff',
|
||||||
|
},
|
||||||
|
markerText: { color: '#fff', fontWeight: '700', fontSize: FontSizes.m },
|
||||||
|
});
|
||||||
199
src/screens/profile/ProfileScreen.tsx
Normal file
199
src/screens/profile/ProfileScreen.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Alert } from 'react-native';
|
||||||
|
import { useAuth } from '@/store/auth';
|
||||||
|
import { AuthApi } from '@/api/client';
|
||||||
|
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
|
||||||
|
import type { DrinkKey, ActivityKey, PurposeKey, Gender } from '@/types/api';
|
||||||
|
|
||||||
|
const DRINKS: { key: DrinkKey; label: string }[] = [
|
||||||
|
{ key: 'beer', label: '🍺 Пиво' },
|
||||||
|
{ key: 'wine', label: '🍷 Вино' },
|
||||||
|
{ key: 'whiskey', label: '🥃 Виски' },
|
||||||
|
{ key: 'vodka', label: '🍸 Водка' },
|
||||||
|
{ key: 'cocktail', label: '🍹 Коктейль' },
|
||||||
|
{ key: 'rum', label: '🥃 Ром' },
|
||||||
|
{ key: 'gin', label: '🍸 Джин' },
|
||||||
|
{ key: 'tequila', label: '🌵 Текила' },
|
||||||
|
{ key: 'champagne', label: '🍾 Шампанское' },
|
||||||
|
{ key: 'non_alcoholic', label: '🥤 Безалкогольное' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTIVITIES: { key: ActivityKey; label: string }[] = [
|
||||||
|
{ key: 'walk', label: '🚶 Прогулка' },
|
||||||
|
{ key: 'dinner', label: '🍽️ Ужин' },
|
||||||
|
{ key: 'bar', label: '🍻 Бар' },
|
||||||
|
{ key: 'cafe', label: '☕ Кафе' },
|
||||||
|
{ key: 'travel', label: '✈️ Путешествие' },
|
||||||
|
{ key: 'movie', label: '🎬 Кино' },
|
||||||
|
{ key: 'concert', label: '🎵 Концерт' },
|
||||||
|
{ key: 'sport', label: '⚽ Спорт' },
|
||||||
|
{ key: 'gaming', label: '🎮 Игры' },
|
||||||
|
{ key: 'reading', label: '📚 Чтение' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PURPOSES: { key: PurposeKey; label: string }[] = [
|
||||||
|
{ key: 'chat', label: '💬 Пообщаться' },
|
||||||
|
{ key: 'drink', label: '🍻 Выпить' },
|
||||||
|
{ key: 'walk', label: '🌳 Погулять' },
|
||||||
|
{ key: 'dinner', label: '🍽️ Поужинать' },
|
||||||
|
{ key: 'travel', label: '✈️ Поехать' },
|
||||||
|
{ key: 'friendship', label: '🤝 Дружба' },
|
||||||
|
{ key: 'relationship', label: '❤️ Отношения' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const { user, prefs, setPrefs, logout } = useAuth();
|
||||||
|
|
||||||
|
const [bio, setBio] = useState(user?.bio ?? '');
|
||||||
|
const [city, setCity] = useState(user?.city ?? '');
|
||||||
|
const [gender, setGender] = useState<Gender | undefined>(user?.gender as Gender);
|
||||||
|
|
||||||
|
const [drinks, setDrinks] = useState<Set<DrinkKey>>(new Set(prefs?.drinks ?? []));
|
||||||
|
const [activities, setActivities] = useState<Set<ActivityKey>>(new Set(prefs?.activities ?? []));
|
||||||
|
const [purposes, setPurposes] = useState<Set<PurposeKey>>(new Set(prefs?.purposes ?? []));
|
||||||
|
|
||||||
|
const toggle = <T,>(set: Set<T>, val: T, setter: (s: Set<T>) => void) => {
|
||||||
|
const next = new Set(set);
|
||||||
|
if (next.has(val)) next.delete(val); else next.add(val);
|
||||||
|
setter(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
try {
|
||||||
|
const updated = await AuthApi.updateMe({ bio, city, gender });
|
||||||
|
// user обновим через store
|
||||||
|
const newPrefs = await AuthApi.updatePrefs({
|
||||||
|
drinks: Array.from(drinks),
|
||||||
|
activities: Array.from(activities),
|
||||||
|
purposes: Array.from(purposes),
|
||||||
|
});
|
||||||
|
setPrefs(newPrefs);
|
||||||
|
Alert.alert('OK', 'Сохранено');
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Ошибка', e?.response?.data?.error ?? 'save failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.root} contentContainerStyle={{ padding: Spacing.l, paddingBottom: 80 }}>
|
||||||
|
<Text style={styles.name}>{user?.name}</Text>
|
||||||
|
{user?.email && <Text style={styles.sub}>{user.email}</Text>}
|
||||||
|
|
||||||
|
<Text style={styles.label}>Город</Text>
|
||||||
|
<TextInput style={styles.input} value={city} onChangeText={setCity} placeholder="Москва" placeholderTextColor={Colors.textDim} />
|
||||||
|
|
||||||
|
<Text style={styles.label}>О себе</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { minHeight: 80, textAlignVertical: 'top' }]}
|
||||||
|
value={bio}
|
||||||
|
onChangeText={setBio}
|
||||||
|
multiline
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="Расскажи о себе"
|
||||||
|
placeholderTextColor={Colors.textDim}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Пол</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
{(['m','f','o'] as Gender[]).map((g) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={g}
|
||||||
|
style={[styles.chip, gender === g && styles.chipActive]}
|
||||||
|
onPress={() => setGender(g)}>
|
||||||
|
<Text style={[styles.chipText, gender === g && styles.chipTextActive]}>
|
||||||
|
{g === 'm' ? 'Муж' : g === 'f' ? 'Жен' : 'Другое'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Section title="Что пью">
|
||||||
|
{DRINKS.map((d) => (
|
||||||
|
<Chip
|
||||||
|
key={d.key}
|
||||||
|
label={d.label}
|
||||||
|
active={drinks.has(d.key)}
|
||||||
|
onPress={() => toggle(drinks, d.key, setDrinks)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Чем занимаюсь">
|
||||||
|
{ACTIVITIES.map((a) => (
|
||||||
|
<Chip
|
||||||
|
key={a.key}
|
||||||
|
label={a.label}
|
||||||
|
active={activities.has(a.key)}
|
||||||
|
onPress={() => toggle(activities, a.key, setActivities)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Цели">
|
||||||
|
{PURPOSES.map((p) => (
|
||||||
|
<Chip
|
||||||
|
key={p.key}
|
||||||
|
label={p.label}
|
||||||
|
active={purposes.has(p.key)}
|
||||||
|
onPress={() => toggle(purposes, p.key, setPurposes)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.saveBtn} onPress={save}>
|
||||||
|
<Text style={styles.saveText}>Сохранить</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.logoutBtn} onPress={logout}>
|
||||||
|
<Text style={styles.logoutText}>Выйти из аккаунта</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: Spacing.l }}>
|
||||||
|
<Text style={styles.section}>{title}</Text>
|
||||||
|
<View style={styles.chipsWrap}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.chip, active && styles.chipActive]}
|
||||||
|
onPress={onPress}>
|
||||||
|
<Text style={[styles.chipText, active && styles.chipTextActive]}>{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: Colors.bg },
|
||||||
|
name: { fontSize: FontSizes.xxl, fontWeight: '700', color: Colors.text },
|
||||||
|
sub: { fontSize: FontSizes.s, color: Colors.textDim, marginBottom: Spacing.l },
|
||||||
|
label: { fontSize: FontSizes.s, color: Colors.textDim, marginTop: Spacing.m, marginBottom: Spacing.xs },
|
||||||
|
input: {
|
||||||
|
borderWidth: 1, borderColor: Colors.border, borderRadius: Radius.m,
|
||||||
|
paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
|
||||||
|
fontSize: FontSizes.m, color: Colors.text,
|
||||||
|
},
|
||||||
|
row: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.s as any },
|
||||||
|
chip: {
|
||||||
|
paddingVertical: Spacing.s, paddingHorizontal: Spacing.m,
|
||||||
|
borderRadius: Radius.full, borderWidth: 1, borderColor: Colors.border,
|
||||||
|
marginRight: Spacing.s, marginBottom: Spacing.s,
|
||||||
|
backgroundColor: Colors.bg,
|
||||||
|
},
|
||||||
|
chipActive: { backgroundColor: Colors.primary, borderColor: Colors.primary },
|
||||||
|
chipText: { color: Colors.text, fontSize: FontSizes.s },
|
||||||
|
chipTextActive: { color: Colors.textInverse, fontWeight: '600' },
|
||||||
|
chipsWrap: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||||
|
section: { fontSize: FontSizes.m, fontWeight: '600', color: Colors.text, marginBottom: Spacing.s },
|
||||||
|
saveBtn: { backgroundColor: Colors.primary, padding: Spacing.m, borderRadius: Radius.m, alignItems: 'center', marginTop: Spacing.xl },
|
||||||
|
saveText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '600' },
|
||||||
|
logoutBtn: { padding: Spacing.m, alignItems: 'center', marginTop: Spacing.m },
|
||||||
|
logoutText: { color: Colors.danger, fontSize: FontSizes.m, fontWeight: '600' },
|
||||||
|
});
|
||||||
79
src/store/auth.ts
Normal file
79
src/store/auth.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { User, Prefs } from '@/types/api';
|
||||||
|
import { AuthApi, setTokens, loadTokens, connectWS, disconnectWS } from '@/api/client';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
prefs: Prefs | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
booted: boolean;
|
||||||
|
boot: () => Promise<void>;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (req: import('@/types/api').RegisterRequest) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
setPrefs: (p: Prefs) => void;
|
||||||
|
setUser: (u: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = create<AuthState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
prefs: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
booted: false,
|
||||||
|
|
||||||
|
async boot() {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const t = await loadTokens();
|
||||||
|
if (!t) {
|
||||||
|
set({ booted: true, loading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const me = await AuthApi.me();
|
||||||
|
set({ user: me.user, prefs: me.prefs, booted: true, loading: false });
|
||||||
|
await connectWS();
|
||||||
|
} catch (e: any) {
|
||||||
|
await setTokens(null);
|
||||||
|
set({ booted: true, loading: false, user: null, prefs: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(email, password) {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const r = await AuthApi.login(email, password);
|
||||||
|
set({ user: r.user, prefs: null, loading: false });
|
||||||
|
await connectWS();
|
||||||
|
const me = await AuthApi.me();
|
||||||
|
set({ prefs: me.prefs });
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.error ?? 'login failed';
|
||||||
|
set({ loading: false, error: msg });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(req) {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const r = await AuthApi.register(req);
|
||||||
|
set({ user: r.user, prefs: null, loading: false });
|
||||||
|
await connectWS();
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.error ?? 'register failed';
|
||||||
|
set({ loading: false, error: msg });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
await AuthApi.logout();
|
||||||
|
disconnectWS();
|
||||||
|
set({ user: null, prefs: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
setPrefs: (p) => set({ prefs: p }),
|
||||||
|
setUser: (u) => set({ user: u }),
|
||||||
|
}));
|
||||||
43
src/store/map.ts
Normal file
43
src/store/map.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Nearby } from '@/types/api';
|
||||||
|
import { AuthApi } from '@/api/client';
|
||||||
|
|
||||||
|
interface MapState {
|
||||||
|
myLat: number | null;
|
||||||
|
myLng: number | null;
|
||||||
|
visible: boolean;
|
||||||
|
radiusKm: number;
|
||||||
|
nearby: Nearby[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
setMyLocation: (lat: number, lng: number) => void;
|
||||||
|
setVisible: (v: boolean) => void;
|
||||||
|
setRadius: (r: number) => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMap = create<MapState>((set, get) => ({
|
||||||
|
myLat: null,
|
||||||
|
myLng: null,
|
||||||
|
visible: true,
|
||||||
|
radiusKm: 5,
|
||||||
|
nearby: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
setMyLocation: (lat, lng) => set({ myLat: lat, myLng: lng }),
|
||||||
|
setVisible: (v) => set({ visible: v }),
|
||||||
|
setRadius: (r) => set({ radiusKm: r }),
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
const { myLat, myLng, radiusKm } = get();
|
||||||
|
if (myLat == null || myLng == null) return;
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const r = await AuthApi.searchNearby(myLat, myLng, radiusKm);
|
||||||
|
set({ nearby: r.results, loading: false });
|
||||||
|
} catch (e: any) {
|
||||||
|
set({ loading: false, error: e?.response?.data?.error ?? 'search failed' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
45
src/theme/colors.ts
Normal file
45
src/theme/colors.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
export const Colors = {
|
||||||
|
primary: '#FF6B35',
|
||||||
|
primaryDark: '#E64A19',
|
||||||
|
secondary: '#1E88E5',
|
||||||
|
bg: '#FFFFFF',
|
||||||
|
bgDark: '#0F0F12',
|
||||||
|
surface: '#F5F5F7',
|
||||||
|
surfaceDark: '#1A1A1F',
|
||||||
|
text: '#111114',
|
||||||
|
textDim: '#6B6B73',
|
||||||
|
textInverse: '#FFFFFF',
|
||||||
|
border: '#E0E0E5',
|
||||||
|
success: '#4CAF50',
|
||||||
|
danger: '#E53935',
|
||||||
|
warning: '#FFB300',
|
||||||
|
online: '#4CAF50',
|
||||||
|
mapMarker: '#FF6B35',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Spacing = {
|
||||||
|
xs: 4,
|
||||||
|
s: 8,
|
||||||
|
m: 12,
|
||||||
|
l: 16,
|
||||||
|
xl: 24,
|
||||||
|
xxl: 32,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FontSizes = {
|
||||||
|
xs: 11,
|
||||||
|
s: 13,
|
||||||
|
m: 15,
|
||||||
|
l: 17,
|
||||||
|
xl: 20,
|
||||||
|
xxl: 28,
|
||||||
|
hero: 34,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Radius = {
|
||||||
|
s: 6,
|
||||||
|
m: 10,
|
||||||
|
l: 16,
|
||||||
|
xl: 24,
|
||||||
|
full: 999,
|
||||||
|
};
|
||||||
109
src/types/api.ts
Normal file
109
src/types/api.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
// API типы (зеркало backend)
|
||||||
|
|
||||||
|
export type Gender = 'm' | 'f' | 'o';
|
||||||
|
export type DrinkKey =
|
||||||
|
| 'beer' | 'wine' | 'whiskey' | 'vodka' | 'cocktail'
|
||||||
|
| 'rum' | 'gin' | 'tequila' | 'cider' | 'champagne' | 'non_alcoholic';
|
||||||
|
|
||||||
|
export type ActivityKey =
|
||||||
|
| 'walk' | 'dinner' | 'bar' | 'cafe' | 'travel' | 'movie'
|
||||||
|
| 'concert' | 'sport' | 'cooking' | 'gaming' | 'reading' | 'other';
|
||||||
|
|
||||||
|
export type PurposeKey =
|
||||||
|
| 'chat' | 'drink' | 'walk' | 'dinner' | 'travel'
|
||||||
|
| 'relationship' | 'friendship' | 'other';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
name: string;
|
||||||
|
birthdate?: string;
|
||||||
|
gender?: Gender;
|
||||||
|
city?: string;
|
||||||
|
bio?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
is_verified?: boolean;
|
||||||
|
created_at: string;
|
||||||
|
last_seen_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Prefs {
|
||||||
|
user_id: string;
|
||||||
|
drinks: DrinkKey[];
|
||||||
|
activities: ActivityKey[];
|
||||||
|
purposes: PurposeKey[];
|
||||||
|
language: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
user: User;
|
||||||
|
prefs: Prefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tokens {
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: User;
|
||||||
|
tokens: Tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Nearby {
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
photo_url?: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
distance_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatListItem {
|
||||||
|
chat: {
|
||||||
|
id: string;
|
||||||
|
user_a: string;
|
||||||
|
user_b: string;
|
||||||
|
created_at: string;
|
||||||
|
last_msg_at: string;
|
||||||
|
};
|
||||||
|
other_id: string;
|
||||||
|
other_name: string;
|
||||||
|
other_photo?: string;
|
||||||
|
last_message: string;
|
||||||
|
last_msg_at: string;
|
||||||
|
unread_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
chat_id: string;
|
||||||
|
sender_id: string;
|
||||||
|
body: string;
|
||||||
|
photo_url?: string;
|
||||||
|
read: boolean;
|
||||||
|
edited: boolean;
|
||||||
|
deleted: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Consents {
|
||||||
|
adult: boolean;
|
||||||
|
terms: boolean;
|
||||||
|
privacy: boolean;
|
||||||
|
disclaimer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
birthdate?: string;
|
||||||
|
gender?: Gender;
|
||||||
|
city?: string;
|
||||||
|
consents: Consents;
|
||||||
|
}
|
||||||
40
src/utils/permissions.ts
Normal file
40
src/utils/permissions.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { PermissionsAndroid, Platform, Alert } from 'react-native';
|
||||||
|
|
||||||
|
export async function requestLocationPermission(): Promise<boolean> {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
try {
|
||||||
|
const granted = await PermissionsAndroid.request(
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
|
{
|
||||||
|
title: 'Доступ к местоположению',
|
||||||
|
message: 'BuhApp использует геолокацию, чтобы показывать других пользователей рядом с вами.',
|
||||||
|
buttonPositive: 'Разрешить',
|
||||||
|
buttonNegative: 'Отмена',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// iOS — Info.plist + native module; assume granted for now (требует react-native-permissions)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обёртка над navigator.geolocation (если установлен @react-native-community/geolocation)
|
||||||
|
export function getCurrentPosition(opts: PositionOptions = {}): Promise<GeolocationPosition> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||||
|
reject(new Error('Geolocation not available'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => resolve(pos),
|
||||||
|
(err) => reject(err),
|
||||||
|
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 60000, ...opts }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Coords { lat: number; lng: number; }
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"module": "esnext",
|
||||||
|
"lib": ["es2020", "dom"],
|
||||||
|
"jsx": "react-native",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "App.tsx", "index.js"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user