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"]
+}