From 4bd3ebae9994fc1dacfac7a161d0ae6f19fac147 Mon Sep 17 00:00:00 2001 From: ga Date: Thu, 20 Aug 2026 15:56:17 +0000 Subject: [PATCH] Sprint 4: React Native mobile app (iOS+Android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 16 ++ App.tsx | 24 +++ README.md | 79 +++++++++- app.json | 4 + babel.config.js | 3 + index.js | 5 + metro.config.js | 2 + package.json | 49 ++++++ src/api/client.ts | 200 +++++++++++++++++++++++++ src/config.ts | 24 +++ src/navigation/index.tsx | 78 ++++++++++ src/screens/auth/LoginScreen.tsx | 77 ++++++++++ src/screens/auth/RegisterScreen.tsx | 179 ++++++++++++++++++++++ src/screens/chat/ChatListScreen.tsx | 96 ++++++++++++ src/screens/chat/ChatScreen.tsx | 208 ++++++++++++++++++++++++++ src/screens/map/MapScreen.tsx | 161 ++++++++++++++++++++ src/screens/profile/ProfileScreen.tsx | 199 ++++++++++++++++++++++++ src/store/auth.ts | 79 ++++++++++ src/store/map.ts | 43 ++++++ src/theme/colors.ts | 45 ++++++ src/types/api.ts | 109 ++++++++++++++ src/utils/permissions.ts | 40 +++++ tsconfig.json | 22 +++ 23 files changed, 1740 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 app.json create mode 100644 babel.config.js create mode 100644 index.js create mode 100644 metro.config.js create mode 100644 package.json create mode 100644 src/api/client.ts create mode 100644 src/config.ts create mode 100644 src/navigation/index.tsx create mode 100644 src/screens/auth/LoginScreen.tsx create mode 100644 src/screens/auth/RegisterScreen.tsx create mode 100644 src/screens/chat/ChatListScreen.tsx create mode 100644 src/screens/chat/ChatScreen.tsx create mode 100644 src/screens/map/MapScreen.tsx create mode 100644 src/screens/profile/ProfileScreen.tsx create mode 100644 src/store/auth.ts create mode 100644 src/store/map.ts create mode 100644 src/theme/colors.ts create mode 100644 src/types/api.ts create mode 100644 src/utils/permissions.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd0b6ed --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..59ecf1f --- /dev/null +++ b/App.tsx @@ -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 ( + + + + + + + ); +} diff --git a/README.md b/README.md index d895e6a..7cb3943 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,78 @@ -# buhapp-mobile +# BuhApp Mobile -React Native приложение (iOS+Android) \ No newline at end of file +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). diff --git a/app.json b/app.json new file mode 100644 index 0000000..48bf538 --- /dev/null +++ b/app.json @@ -0,0 +1,4 @@ +{ + "name": "buhapp-mobile", + "displayName": "BuhApp" +} diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..f7b3da3 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..ab0ecbf --- /dev/null +++ b/index.js @@ -0,0 +1,5 @@ +import { AppRegistry } from 'react-native'; +import App from './App'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 0000000..2cd6790 --- /dev/null +++ b/metro.config.js @@ -0,0 +1,2 @@ +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +module.exports = mergeConfig(getDefaultConfig(__dirname), {}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e3c609 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..56ce9e3 --- /dev/null +++ b/src/api/client.ts @@ -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 { + 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 { + const r = await api.post('/api/v1/auth/register', req); + await setTokens(r.data.tokens); + return r.data; + }, + async login(email: string, password: string): Promise { + const r = await api.post('/api/v1/auth/login', { email, password }); + await setTokens(r.data.tokens); + return r.data; + }, + async me(): Promise { + const r = await api.get('/api/v1/me'); + return r.data; + }, + async updateMe(p: Partial): Promise { + const r = await api.put('/api/v1/me', p); + return r.data; + }, + async getPrefs(): Promise { + const r = await api.get('/api/v1/me/prefs'); + return r.data; + }, + async updatePrefs(p: Partial): Promise { + const r = await api.put('/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; + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..7ece5b9 --- /dev/null +++ b/src/config.ts @@ -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', +}; diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx new file mode 100644 index 0000000..5d1c170 --- /dev/null +++ b/src/navigation/index.tsx @@ -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 ( + + 🗺️, + }} + /> + 💬, + }} + /> + 👤, + }} + /> + + ); +} + +export default function RootNavigation() { + const { user, booted } = useAuth(); + if (!booted) return null; + + return ( + + + {user ? ( + <> + + ({ title: route.params?.otherName ?? 'Чат' })} /> + + ) : ( + <> + + + + )} + + + ); +} diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx new file mode 100644 index 0000000..f8a4ad7 --- /dev/null +++ b/src/screens/auth/LoginScreen.tsx @@ -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(); + 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 ( + + Вход + BuhApp + + Email + + + Пароль + + + {error && {error}} + + + {loading ? 'Вход...' : 'Войти'} + + + nav.navigate('Register')} style={{ marginTop: Spacing.l }}> + Создать аккаунт + + + ); +} + +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 }, +}); diff --git a/src/screens/auth/RegisterScreen.tsx b/src/screens/auth/RegisterScreen.tsx new file mode 100644 index 0000000..5af1ca2 --- /dev/null +++ b/src/screens/auth/RegisterScreen.tsx @@ -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(); + 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(''); + 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 ( + + Регистрация + BuhApp — для совершеннолетних + + + + + + + + + Пол + + {(['m','f','o'] as Gender[]).map((g) => ( + setGender(g)}> + + {g === 'm' ? 'Мужской' : g === 'f' ? 'Женский' : 'Другое'} + + + ))} + + + Согласия (обязательно) + + + + + + {error && {error}} + + + {loading ? 'Отправка...' : 'Создать аккаунт'} + + + nav.navigate('Login')} style={{ marginTop: Spacing.l }}> + У меня уже есть аккаунт + + + ); +} + +function Field({ label, ...props }: any) { + return ( + + {label} + + + ); +} + +function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) { + return ( + + + {label} + + ); +} + +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 }, +}); diff --git a/src/screens/chat/ChatListScreen.tsx b/src/screens/chat/ChatListScreen.tsx new file mode 100644 index 0000000..688e31d --- /dev/null +++ b/src/screens/chat/ChatListScreen.tsx @@ -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(); + const [items, setItems] = useState([]); + 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 ( + + c.chat.id} + refreshing={loading} + onRefresh={load} + ListEmptyComponent={ + Нет чатов. Найди кого-нибудь на карте! + } + renderItem={({ item }) => ( + nav.navigate('Chat', { chatId: item.chat.id, otherId: item.other_id, otherName: item.other_name })}> + {item.other_photo ? ( + + ) : ( + + {item.other_name?.[0] ?? '?'} + + )} + + + {item.other_name} + {formatTime(item.last_msg_at)} + + + + {item.last_message || 'Нет сообщений'} + + {item.unread_count > 0 && ( + + {item.unread_count} + + )} + + + + )} + /> + + ); +} + +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' }, +}); diff --git a/src/screens/chat/ChatScreen.tsx b/src/screens/chat/ChatScreen.tsx new file mode 100644 index 0000000..c9b9ff2 --- /dev/null +++ b/src/screens/chat/ChatScreen.tsx @@ -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(); + const nav = useNavigation(); + const { chatId, otherName } = route.params; + const { user } = useAuth(); + + const [msgs, setMsgs] = useState([]); + const [text, setText] = useState(''); + const [editing, setEditing] = useState(null); + const listRef = useRef(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 ( + + m.id} + contentContainerStyle={{ padding: Spacing.m }} + renderItem={({ item, index }) => { + const mine = item.sender_id === user?.id; + return ( + 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]}> + + {item.body} + + + + {new Date(item.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} + + {item.edited && ( + ред. + )} + + + ); + }} + onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} + /> + + {editing && ( + + Редактирование сообщения + + Отмена + + + )} + + + + + {editing ? '✓' : '➤'} + + + + ); +} + +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' }, +}); diff --git a/src/screens/map/MapScreen.tsx b/src/screens/map/MapScreen.tsx new file mode 100644 index 0000000..47692b6 --- /dev/null +++ b/src/screens/map/MapScreen.tsx @@ -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 ( + + + {myLat != null && myLng != null && ( + + )} + {nearby.map((n) => ( + + + {n.name?.[0] ?? '?'} + + + ))} + + + {/* Верх: имя пользователя + кнопка "обновить" */} + + Привет, {user?.name} 👋 + { + 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); + } + }}> + {loading ? '...' : '⟳'} + + + + {/* Низ: фильтр + видимость */} + + + Я на карте + { + setVisible(v); + AuthApi.setVisibility(v).catch(() => {}); + }} + trackColor={{ true: Colors.primary }} + /> + + + Радиус: {radiusKm} км + + {[1, 5, 10, 25, 50].map((r) => ( + { setRadius(r); refresh(); }} + style={[styles.radiusBtn, radiusKm === r && styles.radiusBtnActive]}> + {r} + + ))} + + + {nearby.length} чел. рядом + + + ); +} + +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 }, +}); diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx new file mode 100644 index 0000000..0ab2275 --- /dev/null +++ b/src/screens/profile/ProfileScreen.tsx @@ -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(user?.gender as Gender); + + const [drinks, setDrinks] = useState>(new Set(prefs?.drinks ?? [])); + const [activities, setActivities] = useState>(new Set(prefs?.activities ?? [])); + const [purposes, setPurposes] = useState>(new Set(prefs?.purposes ?? [])); + + const toggle = (set: Set, val: T, setter: (s: Set) => 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 ( + + {user?.name} + {user?.email && {user.email}} + + Город + + + О себе + + + Пол + + {(['m','f','o'] as Gender[]).map((g) => ( + setGender(g)}> + + {g === 'm' ? 'Муж' : g === 'f' ? 'Жен' : 'Другое'} + + + ))} + + +
+ {DRINKS.map((d) => ( + toggle(drinks, d.key, setDrinks)} + /> + ))} +
+ +
+ {ACTIVITIES.map((a) => ( + toggle(activities, a.key, setActivities)} + /> + ))} +
+ +
+ {PURPOSES.map((p) => ( + toggle(purposes, p.key, setPurposes)} + /> + ))} +
+ + + Сохранить + + + + Выйти из аккаунта + +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + {title} + {children} + + ); +} + +function Chip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { + return ( + + {label} + + ); +} + +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' }, +}); diff --git a/src/store/auth.ts b/src/store/auth.ts new file mode 100644 index 0000000..53bbaf2 --- /dev/null +++ b/src/store/auth.ts @@ -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; + login: (email: string, password: string) => Promise; + register: (req: import('@/types/api').RegisterRequest) => Promise; + logout: () => Promise; + setPrefs: (p: Prefs) => void; + setUser: (u: User) => void; +} + +export const useAuth = create((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 }), +})); diff --git a/src/store/map.ts b/src/store/map.ts new file mode 100644 index 0000000..159d370 --- /dev/null +++ b/src/store/map.ts @@ -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; +} + +export const useMap = create((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' }); + } + }, +})); diff --git a/src/theme/colors.ts b/src/theme/colors.ts new file mode 100644 index 0000000..ed36a83 --- /dev/null +++ b/src/theme/colors.ts @@ -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, +}; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..d1cc2b3 --- /dev/null +++ b/src/types/api.ts @@ -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; +} diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts new file mode 100644 index 0000000..a662a03 --- /dev/null +++ b/src/utils/permissions.ts @@ -0,0 +1,40 @@ +import { PermissionsAndroid, Platform, Alert } from 'react-native'; + +export async function requestLocationPermission(): Promise { + 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 { + 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; } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..877a3a9 --- /dev/null +++ b/tsconfig.json @@ -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"] +}