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
+230 -119
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(
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<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(
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)),
],
),
);
}
}
}
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('Добавить'),
),
],
),
),
],
),
),
);
}
}