Initial commit: family safety frontend project setup

This commit is contained in:
dmit.b
2026-05-09 12:38:19 +03:00
commit ca90c6c3fc
147 changed files with 6350 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
class ApiConfig {
static final String baseUrl = Uri.base.origin;
// Auth endpoints
static final String loginUrl = '$baseUrl/login';
static final String regUrl = '$baseUrl/reg';
// Geo endpoints
static final String geoUrl = '$baseUrl/geo';
// Share endpoints
static final String shareUrl = '$baseUrl/share';
static final String watchUrl = '$baseUrl/watch';
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/auth_provider.dart';
import 'providers/map_provider.dart';
import 'providers/share_provider.dart';
import 'screens/login_screen.dart';
import 'screens/map_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => MapProvider()),
ChangeNotifierProvider(create: (_) => ShareProvider()),
],
child: MaterialApp(
title: 'Family Safety',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
supportedLocales: const [
Locale('en', ''),
],
initialRoute: '/',
routes: {
'/': (_) => const LoginScreen(),
'/map': (_) => const MapScreen(),
},
),
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
class AuthProvider with ChangeNotifier {
final AuthService _authService;
String _token = '';
bool _isLoading = false;
String _error = '';
AuthProvider({AuthService? authService})
: _authService = authService ?? AuthService();
String get token => _token;
bool get isLoggedIn => _token.isNotEmpty;
bool get isLoading => _isLoading;
String get error => _error;
Future<void> login(String login, String password) async {
_isLoading = true;
_error = '';
notifyListeners();
try {
var response = await _authService.login(login, password);
var data = jsonDecode(response);
_token = data['token'];
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> register(String login, String password) async {
_isLoading = true;
_error = '';
notifyListeners();
try {
await _authService.register(login, password);
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
void logout() {
_token = '';
notifyListeners();
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class MapProvider with ChangeNotifier {
bool _isInitialized = false;
String _error = '';
bool get isInitialized => _isInitialized;
String get error => _error;
void initialize() {
_isInitialized = true;
notifyListeners();
}
void handleError(String error) {
_error = error;
notifyListeners();
}
void clearError() {
_error = '';
notifyListeners();
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../services/share_service.dart';
class ShareProvider with ChangeNotifier {
final ShareService _shareService;
String _shareId = '';
int _geoId = 0;
bool _isLoading = false;
String _error = '';
Map<String, dynamic>? _position;
String _trackingShareId = '';
String _trackingToken = '';
Map<String, dynamic>? _trackedPosition;
ShareProvider({ShareService? shareService})
: _shareService = shareService ?? ShareService();
String get shareId => _shareId;
int get geoId => _geoId;
bool get isLoading => _isLoading;
String get error => _error;
Map<String, dynamic>? get position => _position;
String get trackingShareId => _trackingShareId;
Map<String, dynamic>? get trackedPosition => _trackedPosition;
Future<void> createShare(String token, double x, double y) async {
_isLoading = true;
_error = '';
notifyListeners();
try {
final result = await _shareService.createShare(token, x, y);
_geoId = result['geo_id'];
_shareId = result['share_id'];
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> getPosition(String token, String shareID) async {
_isLoading = true;
_error = '';
notifyListeners();
try {
_position = await _shareService.getPosition(token, shareID);
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
void clearShare() {
_shareId = '';
_geoId = 0;
_position = null;
notifyListeners();
}
Future<void> startTracking(String shareId, String token) async {
_trackingShareId = shareId;
_trackingToken = token;
_error = '';
notifyListeners();
await updateTrackedPosition();
}
Future<void> updateTrackedPosition() async {
if (_trackingShareId.isEmpty) return;
try {
_trackedPosition = await _shareService.getPositionByShareId(_trackingToken, _trackingShareId);
notifyListeners();
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
void stopTracking() {
_trackingShareId = '';
_trackingToken = '';
_trackedPosition = null;
notifyListeners();
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
final authProvider = context.watch<AuthProvider>();
final TextEditingController loginController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
return Scaffold(
body: Center(
child: Card(
margin: const EdgeInsets.all(24),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Family Safety',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
TextField(
controller: loginController,
decoration: const InputDecoration(
labelText: 'Login',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
),
const SizedBox(height: 16),
if (authProvider.error.isNotEmpty)
Text(
authProvider.error,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: authProvider.isLoading
? null
: () async {
try {
await authProvider.login(
loginController.text,
passwordController.text,
);
if (!context.mounted) return;
Navigator.of(context).pushReplacementNamed('/map');
} catch (e) {
// Error is handled by provider
}
},
child: const Text('Login'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: authProvider.isLoading
? null
: () async {
try {
await authProvider.register(
loginController.text,
passwordController.text,
);
} catch (e) {
// Error is handled by provider
}
},
child: const Text('Register'),
),
),
],
),
],
),
),
),
),
);
}
}
+255
View File
@@ -0,0 +1,255 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../providers/map_provider.dart';
import '../providers/share_provider.dart';
import '../services/geo_service.dart';
class MapScreen extends StatefulWidget {
const MapScreen({super.key});
@override
State<MapScreen> createState() => _MapScreenState();
}
class _MapScreenState extends State<MapScreen> {
LatLng _center = const LatLng(40.7128, -74.0060);
bool _loading = true;
Timer? _trackingTimer;
Timer? _geoTimer;
@override
void initState() {
super.initState();
_initLocationAndShare();
}
@override
void dispose() {
_trackingTimer?.cancel();
_geoTimer?.cancel();
super.dispose();
}
Future<void> _initLocationAndShare() async {
try {
if (!await Geolocator.isLocationServiceEnabled()) {
setState(() => _loading = false);
return;
}
final position = await Geolocator.getCurrentPosition();
setState(() {
_center = LatLng(position.latitude, position.longitude);
});
if (!mounted) return;
final authProvider = context.read<AuthProvider>();
final shareProvider = context.read<ShareProvider>();
if (authProvider.token.isNotEmpty) {
await shareProvider.createShare(
authProvider.token,
position.longitude,
position.latitude,
);
}
if (!mounted) return;
setState(() => _loading = false);
_geoTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => _updateGeolocation(),
);
} catch (_) {
setState(() => _loading = false);
}
}
Future<void> _updateGeolocation() async {
try {
final position = await Geolocator.getCurrentPosition();
setState(() {
_center = LatLng(position.latitude, position.longitude);
});
if (!mounted) return;
final authProvider = context.read<AuthProvider>();
final shareProvider = context.read<ShareProvider>();
await GeoService().updatePosition(
authProvider.token,
shareProvider.geoId,
position.longitude,
position.latitude,
);
} catch (_) {}
}
void _copyShareLink() {
final shareProvider = context.read<ShareProvider>();
if (shareProvider.shareId.isNotEmpty) {
Clipboard.setData(ClipboardData(text: 'watch/${shareProvider.shareId}'));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ссылка скопирована')),
);
}
}
void _startTracking() {
final authProvider = context.read<AuthProvider>();
final shareProvider = context.read<ShareProvider>();
if (shareProvider.trackingShareId.isEmpty) {
showDialog(
context: context,
builder: (context) {
final TextEditingController _controller = TextEditingController();
return AlertDialog(
title: const Text('Отслеживание'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Введите Share ID для отслеживания:'),
const SizedBox(height: 16),
TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Введите Share ID',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
TextButton(
onPressed: () async {
final shareId = _controller.text.trim();
if (shareId.isNotEmpty) {
Navigator.pop(context);
final token = authProvider.token;
await shareProvider.startTracking(shareId, token);
_trackingTimer = Timer.periodic(
const Duration(seconds: 5),
(_) => shareProvider.updateTrackedPosition(),
);
}
},
child: const Text('Начать'),
),
],
);
},
);
} else {
shareProvider.stopTracking();
_trackingTimer?.cancel();
}
}
@override
Widget build(BuildContext context) {
final authProvider = context.watch<AuthProvider>();
final _ = context.watch<MapProvider>();
final shareProvider = context.watch<ShareProvider>();
if (_loading) {
return Scaffold(
body: const Center(child: CircularProgressIndicator()),
);
}
Marker? trackedMarker;
if (shareProvider.trackedPosition != null) {
final pos = shareProvider.trackedPosition!;
final lat = pos['y'] as double;
final lng = pos['x'] as double;
trackedMarker = Marker(
point: LatLng(lat, lng),
width: 40,
height: 40,
child: const Icon(
Icons.person,
color: Colors.red,
size: 30,
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(
'Family Safety Map\n${_center.latitude.toStringAsFixed(4)}, ${_center.longitude.toStringAsFixed(4)}',
textAlign: TextAlign.center,
),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _updateGeolocation,
),
IconButton(
icon: const Icon(Icons.share),
onPressed: _copyShareLink,
),
IconButton(
icon: Icon(
shareProvider.trackingShareId.isEmpty
? Icons.person_search
: Icons.person,
color: shareProvider.trackingShareId.isEmpty
? null
: Colors.red,
),
onPressed: _startTracking,
),
IconButton(
icon: const Icon(Icons.logout),
onPressed: () {
_geoTimer?.cancel();
authProvider.logout();
Navigator.of(context).pushReplacementNamed('/');
},
),
],
),
body: FlutterMap(
options: MapOptions(
center: _center,
zoom: 12.0,
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
),
MarkerLayer(
markers: [
Marker(
point: _center,
width: 40,
height: 40,
child: const Icon(
Icons.my_location,
color: Colors.blue,
size: 30,)
),
if (trackedMarker != null) trackedMarker,
],
),
],
),
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../providers/share_provider.dart';
class ShareScreen extends StatelessWidget {
const ShareScreen({super.key});
@override
Widget build(BuildContext context) {
final authProvider = context.watch<AuthProvider>();
final shareProvider = context.watch<ShareProvider>();
return Scaffold(
appBar: AppBar(
title: const Text('Share Location'),
),
body: Center(
child: Card(
margin: const EdgeInsets.all(24),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Share Your Location',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
const Text(
'Create a share link to share your location with family',
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
if (shareProvider.shareId.isNotEmpty)
Column(
children: [
Text(
'Share ID: ${shareProvider.shareId}',
style: const TextStyle(
fontSize: 16,
color: Colors.blue,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Copy to clipboard functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Share ID copied to clipboard'),
),
);
},
child: const Text('Copy Share ID'),
),
],
)
else
ElevatedButton(
onPressed: shareProvider.isLoading
? null
: () async {
try {
await shareProvider.createShare(
authProvider.token,
55.7558, // Default Moscow coordinates
37.6173,
);
} catch (e) {
// Error is handled by provider
}
},
child: const Text('Create Share Link'),
),
if (shareProvider.error.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
shareProvider.error,
style: const TextStyle(color: Colors.red),
),
],
],
),
),
),
),
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../config/api.dart';
class AuthService {
final http.Client _client;
AuthService({http.Client? client}) : _client = client ?? http.Client();
Future<String> login(String login, String password) async {
final response = await _client.post(
Uri.parse(ApiConfig.loginUrl),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'login': login, 'password': password}),
);
if (response.statusCode == 200) {
return response.body;
} else {
throw Exception('Invalid credentials');
}
}
Future<void> register(String login, String password) async {
final response = await _client.post(
Uri.parse(ApiConfig.regUrl),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'login': login, 'password': password}),
);
if (response.statusCode != 201) {
throw Exception('Registration failed');
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../config/api.dart';
class GeoService {
final http.Client _client;
GeoService({http.Client? client}) : _client = client ?? http.Client();
Future<void> updatePosition(String token, int id, double x, double y) async {
final response = await _client.put(
Uri.parse('${ApiConfig.geoUrl}?id=$id'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'x': x, 'y': y}),
);
if (response.statusCode != 200) {
throw Exception('Failed to update position');
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../config/api.dart';
class ShareService {
final http.Client _client;
ShareService({http.Client? client}) : _client = client ?? http.Client();
Future<Map<String, dynamic>> createShare(String token, double x, double y) async {
final response = await _client.post(
Uri.parse(ApiConfig.shareUrl),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'x': x, 'y': y}),
);
if (response.statusCode == 201) {
final data = jsonDecode(response.body);
return {
'geo_id': data['geo_id'],
'share_id': data['share_id'],
};
} else {
throw Exception('Failed to create share link');
}
}
Future<Map<String, dynamic>> getPosition(String token, String uniqueId) async {
final response = await _client.get(
Uri.parse('${ApiConfig.watchUrl}?unique_id=$uniqueId'),
headers: {
'Authorization': 'Bearer $token',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return {
'id': data['id'],
'x': data['x'],
'y': data['y'],
'created_at': data['created_at'],
'expires_at': data['expires_at'],
};
} else {
throw Exception('Share link not found or no position available');
}
}
Future<Map<String, dynamic>> getPositionByShareId(String token, String shareId) async {
final response = await _client.get(
Uri.parse('${ApiConfig.watchUrl}?share_id=$shareId'),
headers: {
'Authorization': 'Bearer $token',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return {
'x': data['x'],
'y': data['y'],
'created_at': data['created_at'],
'expires_at': data['expires_at'],
};
} else {
throw Exception('Share link not found or no position available');
}
}
}
+11
View File
@@ -0,0 +1,11 @@
class AppConstants {
static const String appName = 'Family Safety';
static const String defaultMapsApiKey = 'YOUR_MAPS_API_KEY';
// Default coordinates (Moscow)
static const double defaultLatitude = 55.7558;
static const double defaultLongitude = 37.6173;
// Share link expiration (5 minutes in seconds)
static const int shareExpirationSeconds = 300;
}
+22
View File
@@ -0,0 +1,22 @@
class StringUtil {
static bool isValidEmail(String email) {
final regExp = RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)*\.(\w+)$');
return regExp.hasMatch(email);
}
static String truncate(String text, int maxLength) {
if (text.length <= maxLength) return text;
return '${text.substring(0, maxLength)}...';
}
static String formatDateTime(String dateTime) {
try {
final date = DateTime.parse(dateTime);
return '${date.day}/${date.month}/${date.year} '
'${date.hour}:${date.minute.toString().padLeft(2, '0')}';
} catch (e) {
return dateTime;
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class ErrorDisplay extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const ErrorDisplay({
super.key,
required this.message,
this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
if (onRetry != null) ...[
const SizedBox(height: 16),
ElevatedButton(
onPressed: onRetry,
child: const Text('Retry'),
),
],
],
),
),
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class LoadingIndicator extends StatelessWidget {
final String message;
const LoadingIndicator({
super.key,
this.message = 'Loading...',
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(fontSize: 16),
),
],
),
);
}
}