mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Replace hardcoded English strings in 32 Dart source files with l10n references. Covers settings labels, UI actions, map/SAR features, device config, notifications, profiles, spectrum scan, and status messages for sl, de, hr, pl, es, fr, it, el, ru, tr, uk, zh, pt.
777 lines
22 KiB
Dart
777 lines
22 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../models/contact.dart';
|
|
import '../providers/connection_provider.dart';
|
|
import '../providers/contacts_provider.dart';
|
|
import '../services/mesh_map_nodes_service.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
|
|
class RepeatersMapScreen extends StatefulWidget {
|
|
const RepeatersMapScreen({super.key});
|
|
|
|
@override
|
|
State<RepeatersMapScreen> createState() => _RepeatersMapScreenState();
|
|
}
|
|
|
|
class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
|
|
static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058);
|
|
static const double _fallbackZoom = 7;
|
|
static const double _myLocationZoom = 14;
|
|
|
|
final flutter_map.MapController _mapController = flutter_map.MapController();
|
|
|
|
List<MeshMapNode> _onlineRepeaters = const [];
|
|
bool _isLoading = true;
|
|
bool _didFitCamera = false;
|
|
String? _error;
|
|
double _currentZoom = _fallbackZoom;
|
|
LatLng? _myLocation;
|
|
final Set<String> _addingRepeaters = <String>{};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadRepeaters();
|
|
}
|
|
|
|
Future<void> _loadRepeaters({bool forceRefresh = false}) async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
if (forceRefresh) {
|
|
_didFitCamera = false;
|
|
}
|
|
});
|
|
|
|
try {
|
|
final nodes = await MeshMapNodesService.fetchNodes(
|
|
forceRefresh: forceRefresh,
|
|
);
|
|
final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList();
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_onlineRepeaters = repeaters;
|
|
_isLoading = false;
|
|
});
|
|
_refreshMyLocation();
|
|
} catch (error) {
|
|
final cached = await MeshMapNodesService.loadCachedNodes();
|
|
final repeaters = cached.where(MeshMapNodesService.isRepeater).toList();
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_onlineRepeaters = repeaters;
|
|
_isLoading = false;
|
|
_error = repeaters.isEmpty
|
|
? 'Unable to load repeaters right now.'
|
|
: 'Showing cached repeaters. Pull to retry.';
|
|
});
|
|
_refreshMyLocation();
|
|
}
|
|
}
|
|
|
|
List<_MapRepeater> _mergeRepeaters({
|
|
required List<MeshMapNode> onlineNodes,
|
|
required List<Contact> contactRepeaters,
|
|
}) {
|
|
final merged = <String, _MapRepeater>{};
|
|
|
|
for (final node in onlineNodes) {
|
|
merged[node.publicKey] = _MapRepeater(
|
|
publicKey: node.publicKey,
|
|
name: node.name,
|
|
latitude: node.latitude,
|
|
longitude: node.longitude,
|
|
isFromContacts: false,
|
|
isFromOnline: true,
|
|
);
|
|
}
|
|
|
|
for (final contact in contactRepeaters) {
|
|
final location = contact.displayLocation;
|
|
if (location == null) {
|
|
continue;
|
|
}
|
|
|
|
final key = contact.publicKeyHex.toLowerCase();
|
|
final existing = merged[key];
|
|
merged[key] = _MapRepeater(
|
|
publicKey: key,
|
|
name: contact.displayName.isNotEmpty
|
|
? contact.displayName
|
|
: existing?.name ?? 'Unknown repeater',
|
|
latitude: existing?.latitude ?? location.latitude,
|
|
longitude: existing?.longitude ?? location.longitude,
|
|
isFromContacts: true,
|
|
isFromOnline: existing?.isFromOnline ?? false,
|
|
);
|
|
}
|
|
|
|
final repeaters = merged.values.toList()
|
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
return repeaters;
|
|
}
|
|
|
|
Future<void> _refreshMyLocation() async {
|
|
try {
|
|
final position = await Geolocator.getCurrentPosition(
|
|
locationSettings: const LocationSettings(
|
|
accuracy: LocationAccuracy.best,
|
|
distanceFilter: 0,
|
|
),
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_myLocation = LatLng(position.latitude, position.longitude);
|
|
});
|
|
} catch (_) {
|
|
// Best effort only.
|
|
}
|
|
}
|
|
|
|
void _fitCameraIfNeeded(List<_MapRepeater> repeaters) {
|
|
if (_didFitCamera || repeaters.isEmpty || !mounted) {
|
|
return;
|
|
}
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || _didFitCamera || repeaters.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
if (repeaters.length == 1) {
|
|
final repeater = repeaters.first;
|
|
_mapController.move(LatLng(repeater.latitude, repeater.longitude), 13);
|
|
_currentZoom = 13;
|
|
} else {
|
|
final bounds = flutter_map.LatLngBounds.fromPoints(
|
|
repeaters
|
|
.map((node) => LatLng(node.latitude, node.longitude))
|
|
.toList(),
|
|
);
|
|
_mapController.fitCamera(
|
|
flutter_map.CameraFit.bounds(
|
|
bounds: bounds,
|
|
padding: const EdgeInsets.all(48),
|
|
),
|
|
);
|
|
}
|
|
|
|
_didFitCamera = true;
|
|
});
|
|
}
|
|
|
|
void _showRepeaterDetails(_MapRepeater repeater) {
|
|
final isAdding = _addingRepeaters.contains(repeater.publicKey);
|
|
final canAdd = !repeater.isFromContacts;
|
|
final isConnected = context
|
|
.read<ConnectionProvider>()
|
|
.deviceInfo
|
|
.isConnected;
|
|
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder: (context) => SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
repeater.name,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
SizedBox(height: 12),
|
|
_InfoLine(
|
|
label: AppLocalizations.of(context)!.publicKeyLabel,
|
|
value: repeater.publicKey,
|
|
monospace: true,
|
|
),
|
|
_InfoLine(
|
|
label: AppLocalizations.of(context)!.coordinates,
|
|
value:
|
|
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
|
|
),
|
|
_InfoLine(label: AppLocalizations.of(context)!.source, value: repeater.sourceLabel),
|
|
const SizedBox(height: 12),
|
|
if (canAdd)
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
onPressed: !isConnected || isAdding
|
|
? null
|
|
: () async {
|
|
Navigator.of(context).pop();
|
|
await _addRepeaterToContacts(repeater);
|
|
},
|
|
icon: isAdding
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.person_add_alt_1),
|
|
label: Text(
|
|
!isConnected
|
|
? 'Connect device to add'
|
|
: 'Add to contacts',
|
|
),
|
|
),
|
|
)
|
|
else
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
onPressed: null,
|
|
icon: Icon(Icons.check_circle),
|
|
label: Text(AppLocalizations.of(context)!.alreadyInContacts),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _addRepeaterToContacts(_MapRepeater repeater) async {
|
|
if (_addingRepeaters.contains(repeater.publicKey)) {
|
|
return;
|
|
}
|
|
|
|
final connectionProvider = context.read<ConnectionProvider>();
|
|
if (!connectionProvider.deviceInfo.isConnected) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppLocalizations.of(context)!.connectToADeviceBeforeAddingContacts),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_addingRepeaters.add(repeater.publicKey);
|
|
});
|
|
|
|
try {
|
|
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('${repeater.name} added to contacts')),
|
|
);
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to add ${repeater.name}: $error')),
|
|
);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_addingRepeaters.remove(repeater.publicKey);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Uint8List _hexToBytes(String hex) {
|
|
final normalized = hex.replaceAll(':', '').trim().toLowerCase();
|
|
return Uint8List.fromList(
|
|
List<int>.generate(
|
|
normalized.length ~/ 2,
|
|
(index) => int.parse(
|
|
normalized.substring(index * 2, index * 2 + 2),
|
|
radix: 16,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final contactsProvider = context.watch<ContactsProvider>();
|
|
final repeaters = _mergeRepeaters(
|
|
onlineNodes: _onlineRepeaters,
|
|
contactRepeaters: contactsProvider.repeaters,
|
|
);
|
|
_fitCameraIfNeeded(repeaters);
|
|
final clusters = _buildClusters(repeaters);
|
|
final markers = clusters.map((cluster) {
|
|
final markerColor = switch (cluster.status) {
|
|
_ClusterStatus.contactsOnly => const Color(0xFF7C3AED),
|
|
_ClusterStatus.onlineOnly => const Color(0xFFE8681D),
|
|
_ClusterStatus.both => const Color(0xFF1B8F4F),
|
|
_ClusterStatus.mixed => const Color(0xFF2563EB),
|
|
};
|
|
|
|
if (cluster.members.length == 1) {
|
|
final repeater = cluster.members.first;
|
|
return flutter_map.Marker(
|
|
point: cluster.center,
|
|
width: 80,
|
|
height: 100,
|
|
child: GestureDetector(
|
|
onTap: () => _showRepeaterDetails(repeater),
|
|
child: _RepeaterMarker(color: markerColor, label: repeater.name),
|
|
),
|
|
);
|
|
}
|
|
|
|
return flutter_map.Marker(
|
|
point: cluster.center,
|
|
width: 48,
|
|
height: 48,
|
|
child: GestureDetector(
|
|
onTap: () => _zoomToCluster(cluster),
|
|
child: _ClusterMarker(
|
|
color: markerColor,
|
|
count: cluster.members.length,
|
|
),
|
|
),
|
|
);
|
|
}).toList();
|
|
final allMarkers = <flutter_map.Marker>[
|
|
...markers,
|
|
if (_myLocation != null)
|
|
flutter_map.Marker(
|
|
point: _myLocation!,
|
|
width: 24,
|
|
height: 24,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF2563EB),
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 3),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.18),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
];
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(AppLocalizations.of(context)!.repeatersMap),
|
|
actions: [
|
|
IconButton(
|
|
onPressed: _isLoading
|
|
? null
|
|
: () => _loadRepeaters(forceRefresh: true),
|
|
icon: const Icon(Icons.refresh),
|
|
tooltip: 'Refresh',
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
flutter_map.FlutterMap(
|
|
mapController: _mapController,
|
|
options: flutter_map.MapOptions(
|
|
initialCenter: _fallbackCenter,
|
|
initialZoom: _fallbackZoom,
|
|
onPositionChanged: (camera, hasGesture) {
|
|
final nextZoom = camera.zoom;
|
|
if (nextZoom != _currentZoom && mounted) {
|
|
setState(() {
|
|
_currentZoom = nextZoom;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
children: [
|
|
flutter_map.TileLayer(
|
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
userAgentPackageName: 'app.meshcore.sar',
|
|
),
|
|
flutter_map.MarkerLayer(markers: allMarkers),
|
|
],
|
|
),
|
|
if (_error != null)
|
|
Positioned(
|
|
top: 16,
|
|
left: 16,
|
|
right: 16,
|
|
child: Material(
|
|
elevation: 2,
|
|
borderRadius: BorderRadius.circular(14),
|
|
color: Theme.of(context).colorScheme.surface,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
child: Text(_error!),
|
|
),
|
|
),
|
|
),
|
|
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
|
if (!_isLoading && repeaters.isEmpty)
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
_error ?? 'No repeaters available.',
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
if (repeaters.isNotEmpty)
|
|
Positioned(
|
|
left: 16,
|
|
top: _error == null ? 16 : 72,
|
|
child: Material(
|
|
elevation: 2,
|
|
borderRadius: BorderRadius.circular(14),
|
|
color: Theme.of(context).colorScheme.surface,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 10,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_LegendRow(
|
|
color: const Color(0xFF7C3AED),
|
|
label: AppLocalizations.of(context)!.fromContacts,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_LegendRow(
|
|
color: const Color(0xFFE8681D),
|
|
label: AppLocalizations.of(context)!.onlineOnly,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_LegendRow(color: const Color(0xFF1B8F4F), label: AppLocalizations.of(context)!.inBoth),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (repeaters.isNotEmpty)
|
|
Positioned(
|
|
left: 16,
|
|
right: 16,
|
|
bottom: 16,
|
|
child: Material(
|
|
elevation: 3,
|
|
borderRadius: BorderRadius.circular(16),
|
|
color: Theme.of(context).colorScheme.surface,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
child: Text(
|
|
'${repeaters.length} repeaters',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: _jumpToMyLocation,
|
|
tooltip: 'My location',
|
|
child: const Icon(Icons.my_location),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _jumpToMyLocation() async {
|
|
await _refreshMyLocation();
|
|
final myLocation = _myLocation;
|
|
if (!mounted || myLocation == null) {
|
|
return;
|
|
}
|
|
|
|
_mapController.move(myLocation, _myLocationZoom);
|
|
setState(() {
|
|
_currentZoom = _myLocationZoom;
|
|
});
|
|
}
|
|
|
|
List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) {
|
|
if (repeaters.isEmpty) {
|
|
return const [];
|
|
}
|
|
|
|
if (_currentZoom >= 11.5) {
|
|
return repeaters
|
|
.map(
|
|
(repeater) => _RepeaterCluster(
|
|
members: [repeater],
|
|
center: LatLng(repeater.latitude, repeater.longitude),
|
|
status: repeater.clusterStatus,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
final cellSize = _gridSizeForZoom(_currentZoom);
|
|
final buckets = <String, List<_MapRepeater>>{};
|
|
|
|
for (final repeater in repeaters) {
|
|
final latBucket = (repeater.latitude / cellSize).floor();
|
|
final lonBucket = (repeater.longitude / cellSize).floor();
|
|
final key = '$latBucket:$lonBucket';
|
|
buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater);
|
|
}
|
|
|
|
return buckets.values.map((members) {
|
|
final centerLat =
|
|
members.map((node) => node.latitude).reduce((a, b) => a + b) /
|
|
members.length;
|
|
final centerLon =
|
|
members.map((node) => node.longitude).reduce((a, b) => a + b) /
|
|
members.length;
|
|
final statuses = members.map((node) => node.clusterStatus).toSet();
|
|
return _RepeaterCluster(
|
|
members: members,
|
|
center: LatLng(centerLat, centerLon),
|
|
status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed,
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
double _gridSizeForZoom(double zoom) {
|
|
if (zoom < 5) return 3.0;
|
|
if (zoom < 7) return 1.5;
|
|
if (zoom < 9) return 0.7;
|
|
if (zoom < 10.5) return 0.28;
|
|
return 0.12;
|
|
}
|
|
|
|
void _zoomToCluster(_RepeaterCluster cluster) {
|
|
if (cluster.members.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final bounds = flutter_map.LatLngBounds.fromPoints(
|
|
cluster.members
|
|
.map((node) => LatLng(node.latitude, node.longitude))
|
|
.toList(),
|
|
);
|
|
_mapController.fitCamera(
|
|
flutter_map.CameraFit.bounds(
|
|
bounds: bounds,
|
|
padding: const EdgeInsets.all(64),
|
|
maxZoom: (_currentZoom + 2).clamp(8, 14),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InfoLine extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final bool monospace;
|
|
|
|
const _InfoLine({
|
|
required this.label,
|
|
required this.value,
|
|
this.monospace = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 92,
|
|
child: Text(
|
|
label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
value,
|
|
style: monospace
|
|
? const TextStyle(fontFamily: 'monospace')
|
|
: null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LegendRow extends StatelessWidget {
|
|
final Color color;
|
|
final String label;
|
|
|
|
const _LegendRow({required this.color, required this.label});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 12,
|
|
height: 12,
|
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(label),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RepeaterMarker extends StatelessWidget {
|
|
final Color color;
|
|
final String label;
|
|
|
|
const _RepeaterMarker({required this.color, required this.label});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.all(6),
|
|
child: const Icon(Icons.router, color: Colors.white, size: 18),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Container(
|
|
constraints: const BoxConstraints(maxWidth: 80),
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.7),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ClusterMarker extends StatelessWidget {
|
|
final Color color;
|
|
final int count;
|
|
|
|
const _ClusterMarker({required this.color, required this.count});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.22),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 3),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
'$count',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w800,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed }
|
|
|
|
class _RepeaterCluster {
|
|
final List<_MapRepeater> members;
|
|
final LatLng center;
|
|
final _ClusterStatus status;
|
|
|
|
const _RepeaterCluster({
|
|
required this.members,
|
|
required this.center,
|
|
required this.status,
|
|
});
|
|
}
|
|
|
|
class _MapRepeater {
|
|
final String publicKey;
|
|
final String name;
|
|
final double latitude;
|
|
final double longitude;
|
|
final bool isFromContacts;
|
|
final bool isFromOnline;
|
|
|
|
const _MapRepeater({
|
|
required this.publicKey,
|
|
required this.name,
|
|
required this.latitude,
|
|
required this.longitude,
|
|
required this.isFromContacts,
|
|
required this.isFromOnline,
|
|
});
|
|
|
|
String get sourceLabel {
|
|
if (isFromContacts && isFromOnline) return 'Contacts and online';
|
|
if (isFromContacts) return 'Contacts';
|
|
return 'Online';
|
|
}
|
|
|
|
_ClusterStatus get clusterStatus {
|
|
if (isFromContacts && isFromOnline) return _ClusterStatus.both;
|
|
if (isFromContacts) return _ClusterStatus.contactsOnly;
|
|
return _ClusterStatus.onlineOnly;
|
|
}
|
|
}
|