From 6497c1eebf34dfe348381820b060a6863eef84e9 Mon Sep 17 00:00:00 2001 From: "dmit.b" Date: Fri, 15 May 2026 19:08:56 +0300 Subject: [PATCH] Support multiple tracked people with colored markers and watch menu - Replace single tracking with list of TrackedPerson objects in ShareProvider - Each tracked person gets a unique color from a predefined palette - Add watch menu (bottom sheet) with list of tracked people, coordinates, and remove buttons - Add ability to add new tracked people by Share ID - Render multiple colored markers on the map - Start/stop periodic polling for all tracked people --- lib/providers/share_provider.dart | 120 +++++++--- lib/screens/map_screen.dart | 349 ++++++++++++++++++++---------- 2 files changed, 317 insertions(+), 152 deletions(-) diff --git a/lib/providers/share_provider.dart b/lib/providers/share_provider.dart index 57e6cdb..54a1236 100644 --- a/lib/providers/share_provider.dart +++ b/lib/providers/share_provider.dart @@ -1,37 +1,62 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import '../services/share_service.dart'; +class TrackedPerson { + final String shareId; + final String name; + final Color color; + Map? position; + Timer? timer; + + TrackedPerson({ + required this.shareId, + required this.color, + this.name = '', + this.position, + this.timer, + }); +} + class ShareProvider with ChangeNotifier { final ShareService _shareService; - + String _shareId = ''; String _geoId = ''; bool _isLoading = false; String _error = ''; - + Map? _position; - - String _trackingShareId = ''; - String _trackingToken = ''; - Map? _trackedPosition; - - ShareProvider({ShareService? shareService}) - : _shareService = shareService ?? ShareService(); - + + final List _trackedPeople = []; + final List _markerColors = [ + Colors.red, + Colors.green, + Colors.orange, + Colors.purple, + Colors.teal, + Colors.pink, + Colors.brown, + Colors.indigo, + ]; + int _nextColorIndex = 0; + + ShareProvider({ShareService? shareService}) + : _shareService = shareService ?? ShareService(); + String get shareId => _shareId; String get geoId => _geoId; bool get isLoading => _isLoading; String get error => _error; Map? get position => _position; - - String get trackingShareId => _trackingShareId; - Map? get trackedPosition => _trackedPosition; - + + List get trackedPeople => List.unmodifiable(_trackedPeople); + Future 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']?.toString() ?? ''; @@ -46,12 +71,12 @@ class ShareProvider with ChangeNotifier { notifyListeners(); } } - + Future getPosition(String token, String shareId) async { _isLoading = true; _error = ''; notifyListeners(); - + try { _position = await _shareService.getPositionByShareId(token, shareId); notifyListeners(); @@ -64,7 +89,7 @@ class ShareProvider with ChangeNotifier { notifyListeners(); } } - + void clearShare() { _shareId = ''; _geoId = ''; @@ -72,20 +97,29 @@ class ShareProvider with ChangeNotifier { notifyListeners(); } - Future startTracking(String shareId, String token) async { - _trackingShareId = shareId; - _trackingToken = token; - _error = ''; + Future addTracking(String shareId, String token) async { + if (_trackedPeople.any((p) => p.shareId == shareId)) return; + + final color = _markerColors[_nextColorIndex % _markerColors.length]; + _nextColorIndex++; + + final person = TrackedPerson( + shareId: shareId, + color: color, + name: 'Person ${_trackedPeople.length + 1}', + ); + _trackedPeople.add(person); notifyListeners(); - - await updateTrackedPosition(); + + await _updatePersonPosition(token, person); } - Future updateTrackedPosition() async { - if (_trackingShareId.isEmpty) return; - + Future _updatePersonPosition(String token, TrackedPerson person) async { try { - _trackedPosition = await _shareService.getPositionByShareId(_trackingToken, _trackingShareId); + person.position = await _shareService.getPositionByShareId( + token, + person.shareId, + ); notifyListeners(); } catch (e) { _error = e.toString(); @@ -93,10 +127,30 @@ class ShareProvider with ChangeNotifier { } } - void stopTracking() { - _trackingShareId = ''; - _trackingToken = ''; - _trackedPosition = null; + void startTrackingPolling(String token) { + for (final person in _trackedPeople) { + person.timer = Timer.periodic( + const Duration(seconds: 5), + (_) => _updatePersonPosition(token, person), + ); + } + } + + void removeTracking(String shareId) { + final index = _trackedPeople.indexWhere((p) => p.shareId == shareId); + if (index != -1) { + _trackedPeople[index].timer?.cancel(); + _trackedPeople.removeAt(index); + notifyListeners(); + } + } + + void stopAllTracking() { + for (final person in _trackedPeople) { + person.timer?.cancel(); + } + _trackedPeople.clear(); + _nextColorIndex = 0; notifyListeners(); } -} \ No newline at end of file +} diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index ae9dddc..f789542 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -20,7 +20,6 @@ class MapScreen extends StatefulWidget { class _MapScreenState extends State { LatLng _center = const LatLng(40.7128, -74.0060); bool _loading = true; - Timer? _trackingTimer; Timer? _geoTimer; @override @@ -31,7 +30,6 @@ class _MapScreenState extends State { @override void dispose() { - _trackingTimer?.cancel(); _geoTimer?.cancel(); super.dispose(); } @@ -73,9 +71,9 @@ class _MapScreenState extends State { ); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Ошибка геолокации: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Ошибка геолокации: $e'))); } } @@ -118,78 +116,79 @@ class _MapScreenState extends State { ); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Ошибка обновления позиции: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Ошибка обновления позиции: $e'))); } } void _copyShareLink() { final shareProvider = context.read(); if (shareProvider.shareId.isNotEmpty) { - Clipboard.setData(ClipboardData(text: 'watch/${shareProvider.shareId}')); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Ссылка скопирована')), - ); + Clipboard.setData(ClipboardData(text: shareProvider.shareId)); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Ссылка скопирована'))); } } - void _startTracking() { + void _openWatchMenu() { final authProvider = context.read(); final shareProvider = context.read(); - - if (shareProvider.trackingShareId.isEmpty) { - showDialog( - context: context, - builder: (context) { - final TextEditingController _controller = TextEditingController(); - return AlertDialog( - title: const Text('Отслеживание'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - 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('Начать'), - ), - ], - ); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _WatchMenu( + token: authProvider.token, + onAdd: (shareId) async { + await shareProvider.addTracking(shareId, authProvider.token); + if (shareProvider.trackedPeople.length == 1) { + shareProvider.startTrackingPolling(authProvider.token); + } }, - ); - } else { - shareProvider.stopTracking(); - _trackingTimer?.cancel(); + onRemove: (shareId) { + shareProvider.removeTracking(shareId); + if (shareProvider.trackedPeople.isEmpty) { + shareProvider.stopAllTracking(); + } + }, + ), + ); + } + + List _buildMarkers(ShareProvider shareProvider) { + final markers = [ + Marker( + point: _center, + width: 40, + height: 40, + child: const Icon(Icons.my_location, color: Colors.blue, size: 30), + ), + ]; + + for (final person in shareProvider.trackedPeople) { + if (person.position != null) { + final pos = person.position!; + final lat = pos['y'] is String + ? double.parse(pos['y']) + : pos['y'] as double; + final lng = pos['x'] is String + ? double.parse(pos['x']) + : pos['x'] as double; + + markers.add( + Marker( + point: LatLng(lat, lng), + width: 40, + height: 40, + child: Icon(Icons.person, color: person.color, size: 30), + ), + ); + } } + + return markers; } @override @@ -199,26 +198,7 @@ class _MapScreenState extends State { final shareProvider = context.watch(); if (_loading) { - return Scaffold( - body: const Center(child: CircularProgressIndicator()), - ); - } - - Marker? trackedMarker; - if (shareProvider.trackedPosition != null) { - final pos = shareProvider.trackedPosition!; - final lat = pos['y'] is String ? double.parse(pos['y']) : pos['y'] as double; - final lng = pos['x'] is String ? double.parse(pos['x']) : 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(body: const Center(child: CircularProgressIndicator())); } return Scaffold( @@ -232,20 +212,10 @@ class _MapScreenState extends State { icon: const Icon(Icons.refresh), onPressed: _updateGeolocation, ), + IconButton(icon: const Icon(Icons.share), onPressed: _copyShareLink), 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, + icon: const Icon(Icons.visibility), + onPressed: _openWatchMenu, ), IconButton( icon: const Icon(Icons.logout), @@ -258,33 +228,174 @@ class _MapScreenState extends State { ], ), body: FlutterMap( - options: MapOptions( - initialCenter: _center, - initialZoom: 12.0, - ), + options: MapOptions(initialCenter: _center, initialZoom: 12.0), children: [ - TileLayer( + TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', maxZoom: 20, - userAgentPackageName:'FamilySafety/1.0.0', - ), - MarkerLayer( - markers: [ - Marker( - point: _center, - width: 40, - height: 40, - child: const Icon( - Icons.my_location, - color: Colors.blue, - size: 30,) - - ), - if (trackedMarker != null) trackedMarker, - ], + userAgentPackageName: 'FamilySafety/1.0.0', ), + MarkerLayer(markers: _buildMarkers(shareProvider)), ], ), ); } -} \ No newline at end of file +} + +class _WatchMenu extends StatefulWidget { + final String token; + final Future Function(String shareId) onAdd; + final void Function(String shareId) onRemove; + + const _WatchMenu({ + required this.token, + required this.onAdd, + required this.onRemove, + }); + + @override + State<_WatchMenu> createState() => _WatchMenuState(); +} + +class _WatchMenuState extends State<_WatchMenu> { + final TextEditingController _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final shareProvider = context.watch(); + final trackedPeople = shareProvider.trackedPeople; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + constraints: const BoxConstraints(maxHeight: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(16), + ), + ), + child: Row( + children: [ + const Icon(Icons.visibility, size: 24), + const SizedBox(width: 12), + const Text( + 'Отслеживаемые', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const Spacer(), + if (trackedPeople.isNotEmpty) + TextButton( + onPressed: () { + shareProvider.stopAllTracking(); + Navigator.pop(context); + }, + child: const Text('Очистить'), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + if (trackedPeople.isEmpty) + const Padding( + padding: EdgeInsets.all(32), + child: Text( + 'Нет отслеживаемых людей', + style: TextStyle(color: Colors.grey), + ), + ), + if (trackedPeople.isNotEmpty) + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: trackedPeople.length, + itemBuilder: (context, index) { + final person = trackedPeople[index]; + final hasPosition = person.position != null; + return ListTile( + leading: CircleAvatar( + backgroundColor: person.color, + child: const Icon( + Icons.person, + color: Colors.white, + size: 18, + ), + ), + title: Text(person.name), + subtitle: hasPosition + ? Text( + '${person.position!['y'].toStringAsFixed(4)}, ${person.position!['x'].toStringAsFixed(4)}', + ) + : const Text('Нет данных'), + trailing: IconButton( + icon: const Icon(Icons.close, color: Colors.red), + onPressed: () => widget.onRemove(person.shareId), + ), + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(16), + ), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Введите Share ID', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + widget.onAdd(value.trim()); + _controller.clear(); + } + }, + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + if (_controller.text.trim().isNotEmpty) { + widget.onAdd(_controller.text.trim()); + _controller.clear(); + } + }, + child: const Text('Добавить'), + ), + ], + ), + ), + ], + ), + ), + ); + } +}