Sprint 4: React Native mobile app (iOS+Android)

Screens:
- LoginScreen / RegisterScreen (with mandatory consents checkboxes)
- MapScreen — MAIN screen after login (Google Maps on Android, Apple Maps on iOS)
  * shows nearby users within radius
  * visibility toggle (hide/show myself on map)
  * radius selector 1/5/10/25/50 km
  * GPS auto-update with permission flow
- ChatListScreen / ChatScreen with WebSocket realtime
  * long-press message: edit/delete
  * 'ред.' marker for edited messages
  * mark as read
- ProfileScreen — bio, city, gender, drinks, activities, purposes

State: Zustand (auth, map stores)
HTTP: axios + JWT interceptor + token refresh
WS: socket.io-client
Storage: AsyncStorage for tokens
Maps: react-native-maps
Permissions: react-native-permissions
This commit is contained in:
ga 2026-08-20 15:56:17 +00:00
parent 412a257b71
commit 4bd3ebae99
23 changed files with 1740 additions and 2 deletions

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
.DS_Store
.env
.env.local
android/
ios/

24
App.tsx Normal file
View File

@ -0,0 +1,24 @@
import React, { useEffect } from 'react';
import { StatusBar } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useAuth } from '@/store/auth';
import RootNavigation from '@/navigation';
import { Colors } from '@/theme/colors';
export default function App() {
const { boot } = useAuth();
useEffect(() => {
boot();
}, []);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar barStyle="dark-content" backgroundColor={Colors.bg} />
<RootNavigation />
</SafeAreaProvider>
</GestureHandlerRootView>
);
}

View File

@ -1,3 +1,78 @@
# buhapp-mobile
# BuhApp Mobile
React Native приложение (iOS+Android)
React Native приложение для iOS и Android.
## Стек
- React Native 0.74+
- TypeScript
- React Navigation (stack + bottom tabs)
- Zustand (state)
- Axios (HTTP)
- Socket.IO Client (WebSocket)
- react-native-maps (карты)
- @react-native-async-storage/async-storage
- react-native-permissions (location, notifications)
## Структура
```
buhapp-mobile/
├── src/
│ ├── api/ — axios client, endpoints
│ ├── auth/ — токены, auth store
│ ├── screens/ — экраны
│ │ ├── auth/ — Welcome, Register, Login
│ │ ├── map/ — MapScreen (главный экран)
│ │ ├── profile/ — ProfileScreen, EditProfile
│ │ ├── chat/ — ChatListScreen, ChatScreen
│ │ └── settings/
│ ├── components/ — кнопки, формы, элементы
│ ├── navigation/ — навигация
│ ├── store/ — zustand stores
│ ├── ws/ — WebSocket клиент
│ ├── theme/ — цвета, типографика
│ └── types/ — TypeScript типы
├── android/ — Android натив
├── ios/ — iOS натив
├── App.tsx
├── package.json
├── tsconfig.json
└── README.md
```
## Backend API
https://api.buhapp.mygoodservice.ru (или http://localhost:8080 в dev)
## Запуск (dev)
```bash
# 1. Установить зависимости
npm install
# 2. iOS
npx pod-install
npm run ios
# 3. Android
npm run android
```
## Карта (главный экран)
После онбординга пользователь попадает на карту. На ней:
- его собственная точка
- другие пользователи (если включена видимость)
- кнопка "обновить местоположение"
- фильтры (возраст, что пьёт, что делает)
## Юр. документы
При регистрации пользователь принимает:
- Пользовательское соглашение
- Политику конфиденциальности
- Отказ от ответственности
- Подтверждение 18+
См. `/buhapp-docs/legal/` (репозиторий buhapp-docs).

4
app.json Normal file
View File

@ -0,0 +1,4 @@
{
"name": "buhapp-mobile",
"displayName": "BuhApp"
}

3
babel.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
};

5
index.js Normal file
View File

@ -0,0 +1,5 @@
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
AppRegistry.registerComponent(appName, () => App);

2
metro.config.js Normal file
View File

@ -0,0 +1,2 @@
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
module.exports = mergeConfig(getDefaultConfig(__dirname), {});

49
package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "buhapp-mobile",
"version": "0.1.0",
"private": true,
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest",
"tsc": "tsc --noEmit"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.11.0",
"axios": "^1.7.7",
"react": "18.2.0",
"react-native": "0.74.5",
"react-native-gesture-handler": "^2.18.1",
"react-native-maps": "^1.18.0",
"react-native-permissions": "^4.1.5",
"react-native-safe-area-context": "^4.10.9",
"react-native-screens": "^3.34.0",
"socket.io-client": "^4.7.5",
"zustand": "^4.5.5"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/runtime": "^7.25.6",
"@react-native/babel-preset": "0.74.87",
"@react-native/eslint-config": "0.74.87",
"@react-native/metro-config": "0.74.87",
"@react-native/typescript-config": "0.74.87",
"@types/react": "^18.2.6",
"@types/react-test-renderer": "^18.0.0",
"babel-jest": "^29.6.3",
"eslint": "^8.19.0",
"jest": "^29.6.3",
"prettier": "2.8.8",
"react-test-renderer": "18.2.0",
"typescript": "5.0.4"
},
"engines": {
"node": ">=18"
}
}

200
src/api/client.ts Normal file
View File

@ -0,0 +1,200 @@
import axios, { AxiosError, AxiosInstance } from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Config } from '@/config';
import type {
AuthResponse, MeResponse, RegisterRequest, Prefs, Nearby, ChatListItem, Message,
Tokens, User,
} from '@/types/api';
const TOKEN_KEY = 'buhapp.tokens';
let tokens: Tokens | null = null;
let memoryTokens: Tokens | null = null;
export async function loadTokens(): Promise<Tokens | null> {
if (memoryTokens) return memoryTokens;
try {
const raw = await AsyncStorage.getItem(TOKEN_KEY);
if (raw) {
memoryTokens = JSON.parse(raw) as Tokens;
return memoryTokens;
}
} catch (e) {
console.warn('loadTokens error', e);
}
return null;
}
export async function setTokens(t: Tokens | null) {
memoryTokens = t;
if (t) {
await AsyncStorage.setItem(TOKEN_KEY, JSON.stringify(t));
} else {
await AsyncStorage.removeItem(TOKEN_KEY);
}
}
export function getAccessToken(): string | null {
return memoryTokens?.access ?? null;
}
export const api: AxiosInstance = axios.create({
baseURL: Config.API_URL,
timeout: 15000,
});
api.interceptors.request.use(async (cfg) => {
if (!memoryTokens) {
await loadTokens();
}
if (memoryTokens?.access) {
cfg.headers.Authorization = `Bearer ${memoryTokens.access}`;
}
return cfg;
});
api.interceptors.response.use(
(r) => r,
async (err: AxiosError) => {
if (err.response?.status === 401 && memoryTokens?.refresh) {
// попробуем refresh
try {
const r = await axios.post(`${Config.API_URL}/api/v1/auth/refresh`, {
refresh: memoryTokens.refresh,
});
const newT = r.data as Tokens;
await setTokens(newT);
if (err.config) {
err.config.headers.Authorization = `Bearer ${newT.access}`;
return axios(err.config);
}
} catch (e) {
await setTokens(null);
}
}
throw err;
}
);
// ---------- API ----------
export const AuthApi = {
async register(req: RegisterRequest): Promise<AuthResponse> {
const r = await api.post<AuthResponse>('/api/v1/auth/register', req);
await setTokens(r.data.tokens);
return r.data;
},
async login(email: string, password: string): Promise<AuthResponse> {
const r = await api.post<AuthResponse>('/api/v1/auth/login', { email, password });
await setTokens(r.data.tokens);
return r.data;
},
async me(): Promise<MeResponse> {
const r = await api.get<MeResponse>('/api/v1/me');
return r.data;
},
async updateMe(p: Partial<User>): Promise<User> {
const r = await api.put<User>('/api/v1/me', p);
return r.data;
},
async getPrefs(): Promise<Prefs> {
const r = await api.get<Prefs>('/api/v1/me/prefs');
return r.data;
},
async updatePrefs(p: Partial<Prefs>): Promise<Prefs> {
const r = await api.put<Prefs>('/api/v1/me/prefs', p);
return r.data;
},
async updateLocation(lat: number, lng: number, visible: boolean) {
const r = await api.put('/api/v1/me/location', { lat, lng, visible });
return r.data;
},
async setVisibility(visible: boolean) {
const r = await api.put('/api/v1/me/visibility', { visible });
return r.data;
},
async searchNearby(lat: number, lng: number, radiusKm: number): Promise<{ count: number; results: Nearby[] }> {
const r = await api.get('/api/v1/search/nearby', { params: { lat, lng, radius: radiusKm } });
return r.data;
},
async getUser(id: string) {
const r = await api.get(`/api/v1/users/${id}`);
return r.data;
},
async logout() {
await setTokens(null);
},
};
export const ChatApi = {
async ensureChat(otherId: string) {
const r = await api.post('/api/v1/chats', { other_id: otherId });
return r.data;
},
async listChats(): Promise<{ chats: ChatListItem[]; count: number }> {
const r = await api.get('/api/v1/chats');
return r.data;
},
async listMessages(chatId: string) {
const r = await api.get(`/api/v1/chats/${chatId}/messages`);
return r.data;
},
async sendMessage(chatId: string, body?: string, photoUrl?: string) {
const r = await api.post(`/api/v1/chats/${chatId}/messages`, { body, photo_url: photoUrl });
return r.data;
},
async editMessage(msgId: string, body: string) {
const r = await api.put(`/api/v1/messages/${msgId}`, { body });
return r.data;
},
async deleteMessage(msgId: string) {
const r = await api.delete(`/api/v1/messages/${msgId}`);
return r.data;
},
async markRead(chatId: string) {
const r = await api.put(`/api/v1/chats/${chatId}/read`, {});
return r.data;
},
async block(userId: string) {
const r = await api.post('/api/v1/blocks', { user_id: userId });
return r.data;
},
async unblock(userId: string) {
const r = await api.delete(`/api/v1/blocks/${userId}`);
return r.data;
},
async report(targetType: 'user' | 'message' | 'chat', targetId: string, reason: string) {
const r = await api.post('/api/v1/reports', { target_type: targetType, target_id: targetId, reason });
return r.data;
},
};
// ---------- WS ----------
import { io, Socket } from 'socket.io-client';
let socket: Socket | null = null;
export async function connectWS() {
if (socket?.connected) return socket;
const t = await loadTokens();
if (!t) return null;
socket = io(Config.WS_URL, {
transports: ['websocket'],
auth: { token: t.access },
query: { token: t.access },
reconnection: true,
reconnectionDelay: 2000,
});
return socket;
}
export function getSocket(): Socket | null {
return socket;
}
export function disconnectWS() {
if (socket) {
socket.disconnect();
socket = null;
}
}

24
src/config.ts Normal file
View File

@ -0,0 +1,24 @@
import { Platform } from 'react-native';
export const Config = {
// Заменить на свой API URL при деплое
API_URL: __DEV__ ? 'http://localhost:8080' : 'https://api.buhapp.mygoodservice.ru',
WS_URL: __DEV__ ? 'ws://localhost:8080' : 'wss://api.buhapp.mygoodservice.ru',
// карта
DEFAULT_RADIUS_KM: 5,
LOCATION_INTERVAL_MS: 30000,
LOCATION_DISTANCE_M: 50,
// валидация
MIN_PASSWORD_LENGTH: 8,
MIN_AGE: 18,
// прочее
APP_NAME: 'BuhApp',
VERSION: '0.1.0',
// platforms
IS_IOS: Platform.OS === 'ios',
IS_ANDROID: Platform.OS === 'android',
};

78
src/navigation/index.tsx Normal file
View File

@ -0,0 +1,78 @@
import React from 'react';
import { Text, View } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useAuth } from '@/store/auth';
import { Colors, FontSizes } from '@/theme/colors';
import LoginScreen from '@/screens/auth/LoginScreen';
import RegisterScreen from '@/screens/auth/RegisterScreen';
import MapScreen from '@/screens/map/MapScreen';
import ChatListScreen from '@/screens/chat/ChatListScreen';
import ChatScreen from '@/screens/chat/ChatScreen';
import ProfileScreen from '@/screens/profile/ProfileScreen';
const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();
function MainTabs() {
return (
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: Colors.bg },
headerTitleStyle: { color: Colors.text, fontSize: FontSizes.l, fontWeight: '700' },
tabBarActiveTintColor: Colors.primary,
tabBarInactiveTintColor: Colors.textDim,
tabBarStyle: { backgroundColor: Colors.bg, borderTopColor: Colors.border },
}}>
<Tab.Screen
name="Map"
component={MapScreen}
options={{
title: 'Карта',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>🗺</Text>,
}}
/>
<Tab.Screen
name="Chats"
component={ChatListScreen}
options={{
title: 'Чаты',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>💬</Text>,
}}
/>
<Tab.Screen
name="Profile"
component={ProfileScreen}
options={{
title: 'Профиль',
tabBarIcon: ({ color }) => <Text style={{ fontSize: 20, color }}>👤</Text>,
}}
/>
</Tab.Navigator>
);
}
export default function RootNavigation() {
const { user, booted } = useAuth();
if (!booted) return null;
return (
<NavigationContainer>
<Stack.Navigator>
{user ? (
<>
<Stack.Screen name="Main" component={MainTabs} options={{ headerShown: false }} />
<Stack.Screen name="Chat" component={ChatScreen} options={({ route }: any) => ({ title: route.params?.otherName ?? 'Чат' })} />
</>
) : (
<>
<Stack.Screen name="Login" component={LoginScreen} options={{ headerShown: false }} />
<Stack.Screen name="Register" component={RegisterScreen} options={{ headerShown: false }} />
</>
)}
</Stack.Navigator>
</NavigationContainer>
);
}

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useAuth } from '@/store/auth';
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
export default function LoginScreen() {
const nav = useNavigation<any>();
const { login, loading, error } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const onSubmit = async () => {
try {
await login(email.trim().toLowerCase(), password);
} catch (e: any) {
Alert.alert('Ошибка', e?.response?.data?.error ?? 'не удалось войти');
}
};
return (
<View style={styles.root}>
<Text style={styles.title}>Вход</Text>
<Text style={styles.subtitle}>BuhApp</Text>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
placeholder="you@example.com"
placeholderTextColor={Colors.textDim}
/>
<Text style={styles.label}>Пароль</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
secureTextEntry
placeholder="Ваш пароль"
placeholderTextColor={Colors.textDim}
/>
{error && <Text style={styles.error}>{error}</Text>}
<TouchableOpacity
style={[styles.btn, loading && { opacity: 0.5 }]}
disabled={loading}
onPress={onSubmit}>
<Text style={styles.btnText}>{loading ? 'Вход...' : 'Войти'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => nav.navigate('Register')} style={{ marginTop: Spacing.l }}>
<Text style={styles.link}>Создать аккаунт</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: Colors.bg, padding: Spacing.l, paddingTop: Spacing.xxl },
title: { fontSize: FontSizes.xxl, fontWeight: '700', color: Colors.text },
subtitle: { fontSize: FontSizes.m, color: Colors.textDim, marginBottom: Spacing.xl },
label: { fontSize: FontSizes.s, color: Colors.textDim, marginBottom: Spacing.xs, marginTop: Spacing.m },
input: {
borderWidth: 1, borderColor: Colors.border, borderRadius: Radius.m,
paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
fontSize: FontSizes.m, color: Colors.text, backgroundColor: Colors.bg,
},
btn: { backgroundColor: Colors.primary, paddingVertical: Spacing.m, borderRadius: Radius.m, alignItems: 'center', marginTop: Spacing.l },
btnText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '600' },
link: { color: Colors.secondary, textAlign: 'center', fontSize: FontSizes.m },
error: { color: Colors.danger, fontSize: FontSizes.s, marginTop: Spacing.s },
});

View File

@ -0,0 +1,179 @@
import React, { useState } from 'react';
import {
View, Text, TextInput, TouchableOpacity, ScrollView, Switch, StyleSheet, Alert,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useAuth } from '@/store/auth';
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
import { Config } from '@/config';
import type { Gender } from '@/types/api';
export default function RegisterScreen() {
const nav = useNavigation<any>();
const { register, loading, error } = useAuth();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [birthdate, setBirthdate] = useState('');
const [gender, setGender] = useState<Gender | ''>('');
const [city, setCity] = useState('');
const [cAdult, setCAdult] = useState(false);
const [cTerms, setCTerms] = useState(false);
const [cPrivacy, setCPrivacy] = useState(false);
const [cDisclaimer, setCDisclaimer] = useState(false);
const allConsents = cAdult && cTerms && cPrivacy && cDisclaimer;
const canSubmit =
name.trim().length > 0 &&
(email.trim() || phone.trim()) &&
password.length >= Config.MIN_PASSWORD_LENGTH &&
allConsents &&
!loading;
const onSubmit = async () => {
if (!allConsents) {
Alert.alert('Согласия', 'Подтвердите все 4 пункта: 18+, соглашение, политика, отказ');
return;
}
try {
await register({
email: email.trim() || undefined,
phone: phone.trim() || undefined,
password,
name: name.trim(),
birthdate: birthdate.trim() || undefined,
gender: gender || undefined,
city: city.trim() || undefined,
consents: {
adult: cAdult,
terms: cTerms,
privacy: cPrivacy,
disclaimer: cDisclaimer,
},
});
} catch (e: any) {
Alert.alert('Ошибка', e?.response?.data?.error ?? 'не удалось зарегистрироваться');
}
};
return (
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
<Text style={styles.title}>Регистрация</Text>
<Text style={styles.subtitle}>BuhApp для совершеннолетних</Text>
<Field label="Имя" value={name} onChange={setName} placeholder="Как тебя зовут" />
<Field label="Email" value={email} onChange={setEmail} placeholder="you@example.com" keyboardType="email-address" autoCapitalize="none" />
<Field label="Телефон (опционально)" value={phone} onChange={setPhone} placeholder="+79991234567" keyboardType="phone-pad" />
<Field label="Пароль" value={password} onChange={setPassword} placeholder={`минимум ${Config.MIN_PASSWORD_LENGTH} символов`} secureTextEntry />
<Field label="Дата рождения (ГГГГ-ММ-ДД)" value={birthdate} onChange={setBirthdate} placeholder="1995-06-15" autoCapitalize="none" />
<Field label="Город" value={city} onChange={setCity} placeholder="Москва" />
<Text style={styles.label}>Пол</Text>
<View style={styles.row}>
{(['m','f','o'] as Gender[]).map((g) => (
<TouchableOpacity
key={g}
style={[styles.chip, gender === g && styles.chipActive]}
onPress={() => setGender(g)}>
<Text style={[styles.chipText, gender === g && styles.chipTextActive]}>
{g === 'm' ? 'Мужской' : g === 'f' ? 'Женский' : 'Другое'}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={[styles.label, { marginTop: Spacing.l }]}>Согласия (обязательно)</Text>
<ConsentRow
label={`Мне исполнилось 18 лет`}
value={cAdult} onChange={setCAdult}
/>
<ConsentRow
label={`Пользовательское соглашение (версия 1.0)`}
value={cTerms} onChange={setCTerms}
/>
<ConsentRow
label={`Политика конфиденциальности (152-ФЗ, версия 1.0)`}
value={cPrivacy} onChange={setCPrivacy}
/>
<ConsentRow
label={`Отказ от ответственности (версия 1.0)`}
value={cDisclaimer} onChange={setCDisclaimer}
/>
{error && <Text style={styles.error}>{error}</Text>}
<TouchableOpacity
style={[styles.btn, !canSubmit && styles.btnDisabled]}
disabled={!canSubmit}
onPress={onSubmit}>
<Text style={styles.btnText}>{loading ? 'Отправка...' : 'Создать аккаунт'}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => nav.navigate('Login')} style={{ marginTop: Spacing.l }}>
<Text style={styles.link}>У меня уже есть аккаунт</Text>
</TouchableOpacity>
</ScrollView>
);
}
function Field({ label, ...props }: any) {
return (
<View style={{ marginBottom: Spacing.m }}>
<Text style={styles.label}>{label}</Text>
<TextInput
style={styles.input}
placeholderTextColor={Colors.textDim}
{...props}
/>
</View>
);
}
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) {
return (
<View style={styles.consent}>
<Switch
value={value}
onValueChange={onChange}
trackColor={{ true: Colors.primary }}
thumbColor={value ? Colors.primaryDark : '#f4f3f4'}
/>
<Text style={styles.consentText}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: Colors.bg },
content: { padding: Spacing.l, paddingTop: Spacing.xxl },
title: { fontSize: FontSizes.xxl, fontWeight: '700', color: Colors.text },
subtitle: { fontSize: FontSizes.m, color: Colors.textDim, marginBottom: Spacing.l },
label: { fontSize: FontSizes.s, color: Colors.textDim, marginBottom: Spacing.xs },
input: {
borderWidth: 1, borderColor: Colors.border, borderRadius: Radius.m,
paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
fontSize: FontSizes.m, color: Colors.text, backgroundColor: Colors.bg,
},
row: { flexDirection: 'row', gap: Spacing.s as any },
chip: {
paddingVertical: Spacing.s, paddingHorizontal: Spacing.m,
borderRadius: Radius.full, borderWidth: 1, borderColor: Colors.border,
marginRight: Spacing.s,
},
chipActive: { backgroundColor: Colors.primary, borderColor: Colors.primary },
chipText: { color: Colors.text, fontSize: FontSizes.s },
chipTextActive: { color: Colors.textInverse, fontWeight: '600' },
consent: { flexDirection: 'row', alignItems: 'center', marginBottom: Spacing.s, gap: Spacing.s as any },
consentText: { flex: 1, fontSize: FontSizes.s, color: Colors.text },
btn: {
backgroundColor: Colors.primary, paddingVertical: Spacing.m, borderRadius: Radius.m,
alignItems: 'center', marginTop: Spacing.l,
},
btnDisabled: { opacity: 0.5 },
btnText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '600' },
link: { color: Colors.secondary, textAlign: 'center', fontSize: FontSizes.m },
error: { color: Colors.danger, fontSize: FontSizes.s, marginTop: Spacing.s },
});

View File

@ -0,0 +1,96 @@
import React, { useEffect, useState, useCallback } from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Image } from 'react-native';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { ChatApi } from '@/api/client';
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
import type { ChatListItem } from '@/types/api';
export default function ChatListScreen() {
const nav = useNavigation<any>();
const [items, setItems] = useState<ChatListItem[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const r = await ChatApi.listChats();
setItems(r.chats);
} catch (e) {
console.warn(e);
} finally {
setLoading(false);
}
}, []);
useFocusEffect(useCallback(() => { load(); }, [load]));
return (
<View style={styles.root}>
<FlatList
data={items}
keyExtractor={(c) => c.chat.id}
refreshing={loading}
onRefresh={load}
ListEmptyComponent={
<Text style={styles.empty}>Нет чатов. Найди кого-нибудь на карте!</Text>
}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.row}
onPress={() => nav.navigate('Chat', { chatId: item.chat.id, otherId: item.other_id, otherName: item.other_name })}>
{item.other_photo ? (
<Image source={{ uri: item.other_photo }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>{item.other_name?.[0] ?? '?'}</Text>
</View>
)}
<View style={styles.body}>
<View style={styles.header}>
<Text style={styles.name}>{item.other_name}</Text>
<Text style={styles.time}>{formatTime(item.last_msg_at)}</Text>
</View>
<View style={styles.lastRow}>
<Text style={styles.last} numberOfLines={1}>
{item.last_message || 'Нет сообщений'}
</Text>
{item.unread_count > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{item.unread_count}</Text>
</View>
)}
</View>
</View>
</TouchableOpacity>
)}
/>
</View>
);
}
function formatTime(iso: string) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' });
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: Colors.bg },
empty: { textAlign: 'center', marginTop: 50, color: Colors.textDim, fontSize: FontSizes.m, paddingHorizontal: Spacing.l },
row: { flexDirection: 'row', padding: Spacing.m, borderBottomWidth: 1, borderBottomColor: Colors.border, alignItems: 'center' },
avatar: { width: 48, height: 48, borderRadius: 24, marginRight: Spacing.m },
avatarPlaceholder: { backgroundColor: Colors.primary, justifyContent: 'center', alignItems: 'center' },
avatarText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '700' },
body: { flex: 1 },
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 2 },
name: { fontSize: FontSizes.m, fontWeight: '600', color: Colors.text },
time: { fontSize: FontSizes.xs, color: Colors.textDim },
lastRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
last: { fontSize: FontSizes.s, color: Colors.textDim, flex: 1, marginRight: Spacing.s },
badge: { backgroundColor: Colors.primary, borderRadius: Radius.full, minWidth: 20, height: 20, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 6 },
badgeText: { color: Colors.textInverse, fontSize: FontSizes.xs, fontWeight: '700' },
});

View File

@ -0,0 +1,208 @@
import React, { useEffect, useRef, useState } from 'react';
import {
View, Text, TextInput, TouchableOpacity, FlatList, StyleSheet, KeyboardAvoidingView,
Platform, Alert, Modal, Pressable,
} from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { ChatApi, getSocket } from '@/api/client';
import { useAuth } from '@/store/auth';
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
import type { Message } from '@/types/api';
export default function ChatScreen() {
const route = useRoute<any>();
const nav = useNavigation<any>();
const { chatId, otherName } = route.params;
const { user } = useAuth();
const [msgs, setMsgs] = useState<Message[]>([]);
const [text, setText] = useState('');
const [editing, setEditing] = useState<Message | null>(null);
const listRef = useRef<FlatList>(null);
useEffect(() => { load(); markRead(); setupWS(); }, [chatId]);
const load = async () => {
try {
const r = await ChatApi.listMessages(chatId);
setMsgs(r.messages);
} catch (e) {
console.warn(e);
}
};
const markRead = () => ChatApi.markRead(chatId).catch(() => {});
const setupWS = () => {
const sock = getSocket();
if (!sock) return;
const onMessage = (data: any) => {
if (data.type === 'message' && data.payload?.chat_id === chatId) {
setMsgs((prev) => {
if (prev.find((m) => m.id === data.payload.id)) return prev;
return [...prev, data.payload];
});
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100);
markRead();
}
if (data.type === 'message_edited' && data.payload?.chat_id === chatId) {
setMsgs((prev) => prev.map((m) =>
m.id === data.payload.id
? { ...m, body: data.payload.body, edited: data.payload.edited, updated_at: data.payload.updated_at }
: m
));
}
};
sock.on('message', onMessage);
sock.on('message_edited', onMessage);
return () => {
sock.off('message', onMessage);
sock.off('message_edited', onMessage);
};
};
const send = async () => {
const body = text.trim();
if (!body) return;
if (editing) {
try {
await ChatApi.editMessage(editing.id, body);
setMsgs((prev) => prev.map((m) => m.id === editing.id ? { ...m, body, edited: true, updated_at: new Date().toISOString() } : m));
setEditing(null);
setText('');
} catch (e: any) {
Alert.alert('Ошибка', e?.response?.data?.error ?? 'edit failed');
}
return;
}
try {
const m = await ChatApi.sendMessage(chatId, body);
setMsgs((prev) => [...prev, m]);
setText('');
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100);
} catch (e: any) {
Alert.alert('Ошибка', e?.response?.data?.error ?? 'send failed');
}
};
const startEdit = (m: Message) => {
setEditing(m);
setText(m.body);
};
const cancelEdit = () => {
setEditing(null);
setText('');
};
const deleteMsg = (m: Message) => {
Alert.alert('Удалить', 'Удалить это сообщение?', [
{ text: 'Отмена', style: 'cancel' },
{
text: 'Удалить', style: 'destructive',
onPress: async () => {
try {
await ChatApi.deleteMessage(m.id);
setMsgs((prev) => prev.filter((x) => x.id !== m.id));
} catch (e: any) {
Alert.alert('Ошибка', e?.response?.data?.error ?? 'delete failed');
}
},
},
]);
};
return (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={80}>
<FlatList
ref={listRef}
data={msgs}
keyExtractor={(m) => m.id}
contentContainerStyle={{ padding: Spacing.m }}
renderItem={({ item, index }) => {
const mine = item.sender_id === user?.id;
return (
<TouchableOpacity
onLongPress={() => mine && (startEdit(item), Alert.alert('Действие', 'Удалить?', [
{ text: 'Редактировать', onPress: () => startEdit(item) },
{ text: 'Удалить', style: 'destructive', onPress: () => deleteMsg(item) },
{ text: 'Отмена', style: 'cancel' },
]))}
style={[styles.bubble, mine ? styles.bubbleMine : styles.bubbleTheirs]}>
<Text style={[styles.bubbleText, mine && { color: Colors.textInverse }]}>
{item.body}
</Text>
<View style={styles.metaRow}>
<Text style={[styles.meta, mine && { color: 'rgba(255,255,255,0.7)' }]}>
{new Date(item.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</Text>
{item.edited && (
<Text style={[styles.edited, mine && { color: 'rgba(255,255,255,0.7)' }]}>ред.</Text>
)}
</View>
</TouchableOpacity>
);
}}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
/>
{editing && (
<View style={styles.editBar}>
<Text style={styles.editBarText}>Редактирование сообщения</Text>
<TouchableOpacity onPress={cancelEdit}>
<Text style={styles.editBarCancel}>Отмена</Text>
</TouchableOpacity>
</View>
)}
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={text}
onChangeText={setText}
placeholder="Сообщение..."
placeholderTextColor={Colors.textDim}
multiline
/>
<TouchableOpacity
style={[styles.sendBtn, !text.trim() && { opacity: 0.4 }]}
disabled={!text.trim()}
onPress={send}>
<Text style={styles.sendText}>{editing ? '✓' : '➤'}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: Colors.bg },
bubble: { maxWidth: '75%', borderRadius: Radius.l, padding: Spacing.s, marginBottom: Spacing.s },
bubbleMine: { alignSelf: 'flex-end', backgroundColor: Colors.primary, borderBottomRightRadius: 4 },
bubbleTheirs: { alignSelf: 'flex-start', backgroundColor: Colors.surface, borderBottomLeftRadius: 4 },
bubbleText: { fontSize: FontSizes.m, color: Colors.text },
metaRow: { flexDirection: 'row', alignItems: 'center', marginTop: 4, gap: 6 as any },
meta: { fontSize: FontSizes.xs, color: Colors.textDim },
edited: { fontSize: FontSizes.xs, color: Colors.textDim, fontStyle: 'italic' },
inputRow: {
flexDirection: 'row', padding: Spacing.s, backgroundColor: Colors.surface,
borderTopWidth: 1, borderTopColor: Colors.border, alignItems: 'flex-end',
},
input: {
flex: 1, minHeight: 40, maxHeight: 100, backgroundColor: Colors.bg,
borderRadius: Radius.l, paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
fontSize: FontSizes.m, color: Colors.text,
},
sendBtn: { backgroundColor: Colors.primary, width: 40, height: 40, borderRadius: 20, justifyContent: 'center', alignItems: 'center', marginLeft: Spacing.s },
sendText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '700' },
editBar: {
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
padding: Spacing.s, backgroundColor: Colors.surface, borderTopWidth: 1, borderTopColor: Colors.border,
},
editBarText: { color: Colors.text, fontSize: FontSizes.s, fontStyle: 'italic' },
editBarCancel: { color: Colors.danger, fontSize: FontSizes.s, fontWeight: '600' },
});

View File

@ -0,0 +1,161 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, Platform } from 'react-native';
import MapView, { Marker, Circle, PROVIDER_GOOGLE, PROVIDER_DEFAULT } from 'react-native-maps';
import { useAuth } from '@/store/auth';
import { useMap } from '@/store/map';
import { AuthApi } from '@/api/client';
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
import { requestLocationPermission, getCurrentPosition } from '@/utils/permissions';
import { Config } from '@/config';
export default function MapScreen() {
const { user } = useAuth();
const { myLat, myLng, nearby, visible, radiusKm, setMyLocation, setVisible, setRadius, refresh, loading } = useMap();
const [filterOpen, setFilterOpen] = useState(false);
useEffect(() => {
(async () => {
const ok = await requestLocationPermission();
if (!ok) {
Alert.alert('Геолокация', 'Нужен доступ к местоположению, чтобы видеть людей рядом');
return;
}
try {
const pos = await getCurrentPosition();
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setMyLocation(lat, lng);
await AuthApi.updateLocation(lat, lng, visible);
await refresh();
} catch (e: any) {
Alert.alert('Ошибка', e?.message ?? 'не удалось получить координаты');
}
})();
}, []);
useEffect(() => {
if (myLat != null && myLng != null) {
AuthApi.updateLocation(myLat, myLng, visible).catch(() => {});
}
}, [visible]);
const center = myLat != null && myLng != null
? { latitude: myLat, longitude: myLng, latitudeDelta: 0.05, longitudeDelta: 0.05 }
: { latitude: 55.7558, longitude: 37.6173, latitudeDelta: 0.05, longitudeDelta: 0.05 };
return (
<View style={styles.root}>
<MapView
provider={Platform.OS === 'android' ? PROVIDER_GOOGLE : PROVIDER_DEFAULT}
style={styles.map}
initialRegion={center}
region={center}
showsUserLocation
showsMyLocationButton>
{myLat != null && myLng != null && (
<Circle
center={{ latitude: myLat, longitude: myLng }}
radius={radiusKm * 1000}
strokeColor="rgba(255,107,53,0.4)"
fillColor="rgba(255,107,53,0.1)"
/>
)}
{nearby.map((n) => (
<Marker
key={n.user_id}
coordinate={{ latitude: n.lat, longitude: n.lng }}
title={n.name}
description={`${(n.distance_m / 1000).toFixed(1)} км`}>
<View style={styles.marker}>
<Text style={styles.markerText}>{n.name?.[0] ?? '?'}</Text>
</View>
</Marker>
))}
</MapView>
{/* Верх: имя пользователя + кнопка "обновить" */}
<View style={styles.topBar}>
<Text style={styles.welcome}>Привет, {user?.name} 👋</Text>
<TouchableOpacity
style={styles.refreshBtn}
disabled={loading}
onPress={async () => {
try {
const pos = await getCurrentPosition();
setMyLocation(pos.coords.latitude, pos.coords.longitude);
await AuthApi.updateLocation(pos.coords.latitude, pos.coords.longitude, visible);
await refresh();
} catch (e: any) {
Alert.alert('Ошибка', e?.message);
}
}}>
<Text style={styles.refreshText}>{loading ? '...' : '⟳'}</Text>
</TouchableOpacity>
</View>
{/* Низ: фильтр + видимость */}
<View style={styles.bottomBar}>
<View style={styles.row}>
<Text style={styles.label}>Я на карте</Text>
<Switch
value={visible}
onValueChange={(v) => {
setVisible(v);
AuthApi.setVisibility(v).catch(() => {});
}}
trackColor={{ true: Colors.primary }}
/>
</View>
<View style={styles.row}>
<Text style={styles.label}>Радиус: {radiusKm} км</Text>
<View style={styles.radiusBtns}>
{[1, 5, 10, 25, 50].map((r) => (
<TouchableOpacity
key={r}
onPress={() => { setRadius(r); refresh(); }}
style={[styles.radiusBtn, radiusKm === r && styles.radiusBtnActive]}>
<Text style={[styles.radiusBtnText, radiusKm === r && styles.radiusBtnTextActive]}>{r}</Text>
</TouchableOpacity>
))}
</View>
</View>
<Text style={styles.count}>{nearby.length} чел. рядом</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1 },
map: { flex: 1 },
topBar: {
position: 'absolute', top: Spacing.l, left: Spacing.l, right: Spacing.l,
flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.95)', borderRadius: Radius.l,
paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4, elevation: 3,
},
welcome: { fontSize: FontSizes.m, fontWeight: '600', color: Colors.text, flex: 1 },
refreshBtn: { backgroundColor: Colors.primary, width: 36, height: 36, borderRadius: 18, justifyContent: 'center', alignItems: 'center' },
refreshText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '700' },
bottomBar: {
position: 'absolute', bottom: Spacing.l, left: Spacing.l, right: Spacing.l,
backgroundColor: 'rgba(255,255,255,0.95)', borderRadius: Radius.l,
padding: Spacing.m, gap: Spacing.s as any,
shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4, elevation: 3,
},
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
label: { fontSize: FontSizes.s, color: Colors.text, fontWeight: '500' },
radiusBtns: { flexDirection: 'row', gap: Spacing.xs as any },
radiusBtn: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: Radius.full, backgroundColor: Colors.surface, marginLeft: 4 },
radiusBtnActive: { backgroundColor: Colors.primary },
radiusBtnText: { color: Colors.text, fontSize: FontSizes.s },
radiusBtnTextActive: { color: Colors.textInverse, fontWeight: '600' },
count: { fontSize: FontSizes.s, color: Colors.textDim, marginTop: 4 },
marker: {
width: 36, height: 36, borderRadius: 18, backgroundColor: Colors.primary,
justifyContent: 'center', alignItems: 'center',
borderWidth: 2, borderColor: '#fff',
},
markerText: { color: '#fff', fontWeight: '700', fontSize: FontSizes.m },
});

View File

@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet, Alert } from 'react-native';
import { useAuth } from '@/store/auth';
import { AuthApi } from '@/api/client';
import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors';
import type { DrinkKey, ActivityKey, PurposeKey, Gender } from '@/types/api';
const DRINKS: { key: DrinkKey; label: string }[] = [
{ key: 'beer', label: '🍺 Пиво' },
{ key: 'wine', label: '🍷 Вино' },
{ key: 'whiskey', label: '🥃 Виски' },
{ key: 'vodka', label: '🍸 Водка' },
{ key: 'cocktail', label: '🍹 Коктейль' },
{ key: 'rum', label: '🥃 Ром' },
{ key: 'gin', label: '🍸 Джин' },
{ key: 'tequila', label: '🌵 Текила' },
{ key: 'champagne', label: '🍾 Шампанское' },
{ key: 'non_alcoholic', label: '🥤 Безалкогольное' },
];
const ACTIVITIES: { key: ActivityKey; label: string }[] = [
{ key: 'walk', label: '🚶 Прогулка' },
{ key: 'dinner', label: '🍽️ Ужин' },
{ key: 'bar', label: '🍻 Бар' },
{ key: 'cafe', label: '☕ Кафе' },
{ key: 'travel', label: '✈️ Путешествие' },
{ key: 'movie', label: '🎬 Кино' },
{ key: 'concert', label: '🎵 Концерт' },
{ key: 'sport', label: '⚽ Спорт' },
{ key: 'gaming', label: '🎮 Игры' },
{ key: 'reading', label: '📚 Чтение' },
];
const PURPOSES: { key: PurposeKey; label: string }[] = [
{ key: 'chat', label: '💬 Пообщаться' },
{ key: 'drink', label: '🍻 Выпить' },
{ key: 'walk', label: '🌳 Погулять' },
{ key: 'dinner', label: '🍽️ Поужинать' },
{ key: 'travel', label: '✈️ Поехать' },
{ key: 'friendship', label: '🤝 Дружба' },
{ key: 'relationship', label: '❤️ Отношения' },
];
export default function ProfileScreen() {
const { user, prefs, setPrefs, logout } = useAuth();
const [bio, setBio] = useState(user?.bio ?? '');
const [city, setCity] = useState(user?.city ?? '');
const [gender, setGender] = useState<Gender | undefined>(user?.gender as Gender);
const [drinks, setDrinks] = useState<Set<DrinkKey>>(new Set(prefs?.drinks ?? []));
const [activities, setActivities] = useState<Set<ActivityKey>>(new Set(prefs?.activities ?? []));
const [purposes, setPurposes] = useState<Set<PurposeKey>>(new Set(prefs?.purposes ?? []));
const toggle = <T,>(set: Set<T>, val: T, setter: (s: Set<T>) => void) => {
const next = new Set(set);
if (next.has(val)) next.delete(val); else next.add(val);
setter(next);
};
const save = async () => {
try {
const updated = await AuthApi.updateMe({ bio, city, gender });
// user обновим через store
const newPrefs = await AuthApi.updatePrefs({
drinks: Array.from(drinks),
activities: Array.from(activities),
purposes: Array.from(purposes),
});
setPrefs(newPrefs);
Alert.alert('OK', 'Сохранено');
} catch (e: any) {
Alert.alert('Ошибка', e?.response?.data?.error ?? 'save failed');
}
};
return (
<ScrollView style={styles.root} contentContainerStyle={{ padding: Spacing.l, paddingBottom: 80 }}>
<Text style={styles.name}>{user?.name}</Text>
{user?.email && <Text style={styles.sub}>{user.email}</Text>}
<Text style={styles.label}>Город</Text>
<TextInput style={styles.input} value={city} onChangeText={setCity} placeholder="Москва" placeholderTextColor={Colors.textDim} />
<Text style={styles.label}>О себе</Text>
<TextInput
style={[styles.input, { minHeight: 80, textAlignVertical: 'top' }]}
value={bio}
onChangeText={setBio}
multiline
maxLength={1000}
placeholder="Расскажи о себе"
placeholderTextColor={Colors.textDim}
/>
<Text style={styles.label}>Пол</Text>
<View style={styles.row}>
{(['m','f','o'] as Gender[]).map((g) => (
<TouchableOpacity
key={g}
style={[styles.chip, gender === g && styles.chipActive]}
onPress={() => setGender(g)}>
<Text style={[styles.chipText, gender === g && styles.chipTextActive]}>
{g === 'm' ? 'Муж' : g === 'f' ? 'Жен' : 'Другое'}
</Text>
</TouchableOpacity>
))}
</View>
<Section title="Что пью">
{DRINKS.map((d) => (
<Chip
key={d.key}
label={d.label}
active={drinks.has(d.key)}
onPress={() => toggle(drinks, d.key, setDrinks)}
/>
))}
</Section>
<Section title="Чем занимаюсь">
{ACTIVITIES.map((a) => (
<Chip
key={a.key}
label={a.label}
active={activities.has(a.key)}
onPress={() => toggle(activities, a.key, setActivities)}
/>
))}
</Section>
<Section title="Цели">
{PURPOSES.map((p) => (
<Chip
key={p.key}
label={p.label}
active={purposes.has(p.key)}
onPress={() => toggle(purposes, p.key, setPurposes)}
/>
))}
</Section>
<TouchableOpacity style={styles.saveBtn} onPress={save}>
<Text style={styles.saveText}>Сохранить</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.logoutBtn} onPress={logout}>
<Text style={styles.logoutText}>Выйти из аккаунта</Text>
</TouchableOpacity>
</ScrollView>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={{ marginTop: Spacing.l }}>
<Text style={styles.section}>{title}</Text>
<View style={styles.chipsWrap}>{children}</View>
</View>
);
}
function Chip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
return (
<TouchableOpacity
style={[styles.chip, active && styles.chipActive]}
onPress={onPress}>
<Text style={[styles.chipText, active && styles.chipTextActive]}>{label}</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: Colors.bg },
name: { fontSize: FontSizes.xxl, fontWeight: '700', color: Colors.text },
sub: { fontSize: FontSizes.s, color: Colors.textDim, marginBottom: Spacing.l },
label: { fontSize: FontSizes.s, color: Colors.textDim, marginTop: Spacing.m, marginBottom: Spacing.xs },
input: {
borderWidth: 1, borderColor: Colors.border, borderRadius: Radius.m,
paddingHorizontal: Spacing.m, paddingVertical: Spacing.s,
fontSize: FontSizes.m, color: Colors.text,
},
row: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.s as any },
chip: {
paddingVertical: Spacing.s, paddingHorizontal: Spacing.m,
borderRadius: Radius.full, borderWidth: 1, borderColor: Colors.border,
marginRight: Spacing.s, marginBottom: Spacing.s,
backgroundColor: Colors.bg,
},
chipActive: { backgroundColor: Colors.primary, borderColor: Colors.primary },
chipText: { color: Colors.text, fontSize: FontSizes.s },
chipTextActive: { color: Colors.textInverse, fontWeight: '600' },
chipsWrap: { flexDirection: 'row', flexWrap: 'wrap' },
section: { fontSize: FontSizes.m, fontWeight: '600', color: Colors.text, marginBottom: Spacing.s },
saveBtn: { backgroundColor: Colors.primary, padding: Spacing.m, borderRadius: Radius.m, alignItems: 'center', marginTop: Spacing.xl },
saveText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '600' },
logoutBtn: { padding: Spacing.m, alignItems: 'center', marginTop: Spacing.m },
logoutText: { color: Colors.danger, fontSize: FontSizes.m, fontWeight: '600' },
});

79
src/store/auth.ts Normal file
View File

@ -0,0 +1,79 @@
import { create } from 'zustand';
import type { User, Prefs } from '@/types/api';
import { AuthApi, setTokens, loadTokens, connectWS, disconnectWS } from '@/api/client';
interface AuthState {
user: User | null;
prefs: Prefs | null;
loading: boolean;
error: string | null;
booted: boolean;
boot: () => Promise<void>;
login: (email: string, password: string) => Promise<void>;
register: (req: import('@/types/api').RegisterRequest) => Promise<void>;
logout: () => Promise<void>;
setPrefs: (p: Prefs) => void;
setUser: (u: User) => void;
}
export const useAuth = create<AuthState>((set) => ({
user: null,
prefs: null,
loading: false,
error: null,
booted: false,
async boot() {
set({ loading: true });
try {
const t = await loadTokens();
if (!t) {
set({ booted: true, loading: false });
return;
}
const me = await AuthApi.me();
set({ user: me.user, prefs: me.prefs, booted: true, loading: false });
await connectWS();
} catch (e: any) {
await setTokens(null);
set({ booted: true, loading: false, user: null, prefs: null });
}
},
async login(email, password) {
set({ loading: true, error: null });
try {
const r = await AuthApi.login(email, password);
set({ user: r.user, prefs: null, loading: false });
await connectWS();
const me = await AuthApi.me();
set({ prefs: me.prefs });
} catch (e: any) {
const msg = e?.response?.data?.error ?? 'login failed';
set({ loading: false, error: msg });
throw e;
}
},
async register(req) {
set({ loading: true, error: null });
try {
const r = await AuthApi.register(req);
set({ user: r.user, prefs: null, loading: false });
await connectWS();
} catch (e: any) {
const msg = e?.response?.data?.error ?? 'register failed';
set({ loading: false, error: msg });
throw e;
}
},
async logout() {
await AuthApi.logout();
disconnectWS();
set({ user: null, prefs: null });
},
setPrefs: (p) => set({ prefs: p }),
setUser: (u) => set({ user: u }),
}));

43
src/store/map.ts Normal file
View File

@ -0,0 +1,43 @@
import { create } from 'zustand';
import type { Nearby } from '@/types/api';
import { AuthApi } from '@/api/client';
interface MapState {
myLat: number | null;
myLng: number | null;
visible: boolean;
radiusKm: number;
nearby: Nearby[];
loading: boolean;
error: string | null;
setMyLocation: (lat: number, lng: number) => void;
setVisible: (v: boolean) => void;
setRadius: (r: number) => void;
refresh: () => Promise<void>;
}
export const useMap = create<MapState>((set, get) => ({
myLat: null,
myLng: null,
visible: true,
radiusKm: 5,
nearby: [],
loading: false,
error: null,
setMyLocation: (lat, lng) => set({ myLat: lat, myLng: lng }),
setVisible: (v) => set({ visible: v }),
setRadius: (r) => set({ radiusKm: r }),
async refresh() {
const { myLat, myLng, radiusKm } = get();
if (myLat == null || myLng == null) return;
set({ loading: true, error: null });
try {
const r = await AuthApi.searchNearby(myLat, myLng, radiusKm);
set({ nearby: r.results, loading: false });
} catch (e: any) {
set({ loading: false, error: e?.response?.data?.error ?? 'search failed' });
}
},
}));

45
src/theme/colors.ts Normal file
View File

@ -0,0 +1,45 @@
export const Colors = {
primary: '#FF6B35',
primaryDark: '#E64A19',
secondary: '#1E88E5',
bg: '#FFFFFF',
bgDark: '#0F0F12',
surface: '#F5F5F7',
surfaceDark: '#1A1A1F',
text: '#111114',
textDim: '#6B6B73',
textInverse: '#FFFFFF',
border: '#E0E0E5',
success: '#4CAF50',
danger: '#E53935',
warning: '#FFB300',
online: '#4CAF50',
mapMarker: '#FF6B35',
};
export const Spacing = {
xs: 4,
s: 8,
m: 12,
l: 16,
xl: 24,
xxl: 32,
};
export const FontSizes = {
xs: 11,
s: 13,
m: 15,
l: 17,
xl: 20,
xxl: 28,
hero: 34,
};
export const Radius = {
s: 6,
m: 10,
l: 16,
xl: 24,
full: 999,
};

109
src/types/api.ts Normal file
View File

@ -0,0 +1,109 @@
// API типы (зеркало backend)
export type Gender = 'm' | 'f' | 'o';
export type DrinkKey =
| 'beer' | 'wine' | 'whiskey' | 'vodka' | 'cocktail'
| 'rum' | 'gin' | 'tequila' | 'cider' | 'champagne' | 'non_alcoholic';
export type ActivityKey =
| 'walk' | 'dinner' | 'bar' | 'cafe' | 'travel' | 'movie'
| 'concert' | 'sport' | 'cooking' | 'gaming' | 'reading' | 'other';
export type PurposeKey =
| 'chat' | 'drink' | 'walk' | 'dinner' | 'travel'
| 'relationship' | 'friendship' | 'other';
export interface User {
id: string;
email?: string;
phone?: string;
name: string;
birthdate?: string;
gender?: Gender;
city?: string;
bio?: string;
photo_url?: string;
is_verified?: boolean;
created_at: string;
last_seen_at?: string;
}
export interface Prefs {
user_id: string;
drinks: DrinkKey[];
activities: ActivityKey[];
purposes: PurposeKey[];
language: string;
updated_at: string;
}
export interface MeResponse {
user: User;
prefs: Prefs;
}
export interface Tokens {
access: string;
refresh: string;
}
export interface AuthResponse {
user: User;
tokens: Tokens;
}
export interface Nearby {
user_id: string;
name: string;
photo_url?: string;
lat: number;
lng: number;
distance_m: number;
}
export interface ChatListItem {
chat: {
id: string;
user_a: string;
user_b: string;
created_at: string;
last_msg_at: string;
};
other_id: string;
other_name: string;
other_photo?: string;
last_message: string;
last_msg_at: string;
unread_count: number;
}
export interface Message {
id: string;
chat_id: string;
sender_id: string;
body: string;
photo_url?: string;
read: boolean;
edited: boolean;
deleted: boolean;
created_at: string;
updated_at: string;
}
export interface Consents {
adult: boolean;
terms: boolean;
privacy: boolean;
disclaimer: boolean;
}
export interface RegisterRequest {
email?: string;
phone?: string;
password: string;
name: string;
birthdate?: string;
gender?: Gender;
city?: string;
consents: Consents;
}

40
src/utils/permissions.ts Normal file
View File

@ -0,0 +1,40 @@
import { PermissionsAndroid, Platform, Alert } from 'react-native';
export async function requestLocationPermission(): Promise<boolean> {
if (Platform.OS === 'android') {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
{
title: 'Доступ к местоположению',
message: 'BuhApp использует геолокацию, чтобы показывать других пользователей рядом с вами.',
buttonPositive: 'Разрешить',
buttonNegative: 'Отмена',
}
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
} catch (err) {
console.warn(err);
return false;
}
}
// iOS — Info.plist + native module; assume granted for now (требует react-native-permissions)
return true;
}
// Обёртка над navigator.geolocation (если установлен @react-native-community/geolocation)
export function getCurrentPosition(opts: PositionOptions = {}): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
reject(new Error('Geolocation not available'));
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => resolve(pos),
(err) => reject(err),
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 60000, ...opts }
);
});
}
export interface Coords { lat: number; lng: number; }

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["es2020", "dom"],
"jsx": "react-native",
"strict": true,
"noImplicitAny": false,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": "./src",
"paths": {
"@/*": ["*"]
}
},
"include": ["src/**/*", "App.tsx", "index.js"]
}