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
This commit is contained in:
dmit.b
2026-05-15 19:08:56 +03:00
parent 00b0aaf65e
commit 6497c1eebf
2 changed files with 317 additions and 152 deletions
+72 -18
View File
@@ -1,6 +1,23 @@
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<String, dynamic>? position;
Timer? timer;
TrackedPerson({
required this.shareId,
required this.color,
this.name = '',
this.position,
this.timer,
});
}
class ShareProvider with ChangeNotifier {
final ShareService _shareService;
@@ -11,9 +28,18 @@ class ShareProvider with ChangeNotifier {
Map<String, dynamic>? _position;
String _trackingShareId = '';
String _trackingToken = '';
Map<String, dynamic>? _trackedPosition;
final List<TrackedPerson> _trackedPeople = [];
final List<Color> _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();
@@ -24,8 +50,7 @@ class ShareProvider with ChangeNotifier {
String get error => _error;
Map<String, dynamic>? get position => _position;
String get trackingShareId => _trackingShareId;
Map<String, dynamic>? get trackedPosition => _trackedPosition;
List<TrackedPerson> get trackedPeople => List.unmodifiable(_trackedPeople);
Future<void> createShare(String token, double x, double y) async {
_isLoading = true;
@@ -72,20 +97,29 @@ class ShareProvider with ChangeNotifier {
notifyListeners();
}
Future<void> startTracking(String shareId, String token) async {
_trackingShareId = shareId;
_trackingToken = token;
_error = '';
Future<void> 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<void> updateTrackedPosition() async {
if (_trackingShareId.isEmpty) return;
Future<void> _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();
}
}
+224 -113
View File
@@ -20,7 +20,6 @@ class MapScreen extends StatefulWidget {
class _MapScreenState extends State<MapScreen> {
LatLng _center = const LatLng(40.7128, -74.0060);
bool _loading = true;
Timer? _trackingTimer;
Timer? _geoTimer;
@override
@@ -31,7 +30,6 @@ class _MapScreenState extends State<MapScreen> {
@override
void dispose() {
_trackingTimer?.cancel();
_geoTimer?.cancel();
super.dispose();
}
@@ -73,9 +71,9 @@ class _MapScreenState extends State<MapScreen> {
);
} 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<MapScreen> {
);
} 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<ShareProvider>();
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<AuthProvider>();
final shareProvider = context.read<ShareProvider>();
if (shareProvider.trackingShareId.isEmpty) {
showDialog(
showModalBottomSheet(
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(),
);
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);
}
},
child: const Text('Начать'),
),
],
);
},
);
} else {
shareProvider.stopTracking();
_trackingTimer?.cancel();
onRemove: (shareId) {
shareProvider.removeTracking(shareId);
if (shareProvider.trackedPeople.isEmpty) {
shareProvider.stopAllTracking();
}
},
),
);
}
List<Marker> _buildMarkers(ShareProvider shareProvider) {
final markers = <Marker>[
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<MapScreen> {
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'] 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<MapScreen> {
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<MapScreen> {
],
),
body: FlutterMap(
options: MapOptions(
initialCenter: _center,
initialZoom: 12.0,
),
options: MapOptions(initialCenter: _center, initialZoom: 12.0),
children: [
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,
],
),
MarkerLayer(markers: _buildMarkers(shareProvider)),
],
),
);
}
}
class _WatchMenu extends StatefulWidget {
final String token;
final Future<void> 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<ShareProvider>();
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('Добавить'),
),
],
),
),
],
),
),
);
}
}