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