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.
475 lines
16 KiB
Dart
475 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../models/contact.dart';
|
|
import '../../models/location_trail.dart';
|
|
import '../../providers/map_provider.dart';
|
|
import '../../providers/contacts_provider.dart';
|
|
import '../../services/gpx_service.dart';
|
|
import '../../services/trail_color_service.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
|
|
/// Trail management controls widget
|
|
class TrailControls extends StatelessWidget {
|
|
const TrailControls({super.key});
|
|
|
|
void _showTrailMenu(BuildContext context) {
|
|
final mapProvider = Provider.of<MapProvider>(context, listen: false);
|
|
final contactsProvider = Provider.of<ContactsProvider>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
// Get contacts with trails (advertHistory >= 2 points)
|
|
final contactsWithTrails = contactsProvider.contactsWithLocation
|
|
.where((c) => c.advertHistory.length >= 2)
|
|
.toList();
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setModalState) => SingleChildScrollView(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.timeline, size: 24),
|
|
SizedBox(width: 12),
|
|
Text(
|
|
l10n.locationTrail,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Trail visibility toggle
|
|
SwitchListTile(
|
|
secondary: Icon(Icons.visibility),
|
|
title: Text(l10n.showTrailOnMap),
|
|
subtitle: Text(
|
|
mapProvider.isTrailVisible
|
|
? l10n.trailVisible
|
|
: l10n.trailHiddenRecording,
|
|
),
|
|
value: mapProvider.isTrailVisible,
|
|
onChanged: (value) {
|
|
mapProvider.toggleTrailVisibility();
|
|
setModalState(() {}); // Update modal UI
|
|
},
|
|
),
|
|
const Divider(),
|
|
const SizedBox(height: 8),
|
|
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: () => _importTrail(context, mapProvider),
|
|
icon: Icon(Icons.file_upload_outlined),
|
|
label: Text(l10n.importFromClipboard),
|
|
),
|
|
if (mapProvider.currentTrail != null &&
|
|
mapProvider.currentTrail!.points.length >= 2)
|
|
OutlinedButton.icon(
|
|
onPressed: () => _exportTrail(
|
|
context,
|
|
mapProvider.currentTrail!,
|
|
customName: 'My Trail',
|
|
),
|
|
icon: Icon(Icons.file_download_outlined),
|
|
label: Text(l10n.exportToClipboard),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Trail stats
|
|
if (mapProvider.currentTrail != null &&
|
|
mapProvider.currentTrail!.points.isNotEmpty)
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: Colors.blue.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildStatRow(
|
|
icon: Icons.straighten,
|
|
label: l10n.distance,
|
|
value: _formatDistance(
|
|
mapProvider.totalTrailDistance,
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
_buildStatRow(
|
|
icon: Icons.access_time,
|
|
label: l10n.duration,
|
|
value: _formatDuration(mapProvider.trailDuration),
|
|
),
|
|
SizedBox(height: 8),
|
|
_buildStatRow(
|
|
icon: Icons.place,
|
|
label: l10n.points,
|
|
value: '${mapProvider.currentTrail!.points.length}',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Clear trail button
|
|
if (mapProvider.currentTrail != null &&
|
|
mapProvider.currentTrail!.points.isNotEmpty)
|
|
ElevatedButton.icon(
|
|
onPressed: () {
|
|
_showClearConfirmation(context, mapProvider, l10n);
|
|
},
|
|
icon: Icon(Icons.delete_outline),
|
|
label: Text(l10n.clearTrail),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.all(16),
|
|
),
|
|
),
|
|
|
|
// No trail message
|
|
if (mapProvider.currentTrail == null ||
|
|
mapProvider.currentTrail!.points.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Center(
|
|
child: Column(
|
|
children: [
|
|
const Icon(
|
|
Icons.timeline,
|
|
size: 48,
|
|
color: Colors.grey,
|
|
),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
l10n.noTrailRecorded,
|
|
style: const TextStyle(
|
|
color: Colors.grey,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
l10n.startTrackingToRecord,
|
|
style: const TextStyle(
|
|
color: Colors.grey,
|
|
fontSize: 12,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
const Divider(),
|
|
const SizedBox(height: 8),
|
|
|
|
// Contact Trails Section
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.people, size: 20),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
l10n.contactTrails,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Show All Contact Trails toggle
|
|
SwitchListTile(
|
|
secondary: Icon(Icons.route),
|
|
title: Text(l10n.showAllContactTrails),
|
|
subtitle: Text(
|
|
contactsWithTrails.isEmpty
|
|
? l10n.noContactsWithLocationHistory
|
|
: mapProvider.showAllContactTrails
|
|
? l10n.showingTrailsForContacts(
|
|
contactsWithTrails.length,
|
|
)
|
|
: l10n.individualContactTrails,
|
|
),
|
|
value: mapProvider.showAllContactTrails,
|
|
onChanged: contactsWithTrails.isNotEmpty
|
|
? (value) {
|
|
mapProvider.toggleAllContactTrails();
|
|
setModalState(() {}); // Update modal UI
|
|
}
|
|
: null, // Disable if no contacts with trails
|
|
),
|
|
|
|
// Individual contact trails (when "show all" is OFF)
|
|
if (!mapProvider.showAllContactTrails &&
|
|
contactsWithTrails.isNotEmpty)
|
|
ExpansionTile(
|
|
title: Text(l10n.individualContactTrails),
|
|
initiallyExpanded: false,
|
|
children: contactsWithTrails.map((contact) {
|
|
final trailColor = TrailColorService.getTrailColor(
|
|
contact,
|
|
);
|
|
final isVisible = mapProvider.isContactPathVisible(
|
|
contact.publicKeyHex,
|
|
);
|
|
|
|
return SwitchListTile(
|
|
// Color indicator with emoji
|
|
secondary: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (contact.roleEmoji != null)
|
|
Text(
|
|
contact.roleEmoji!,
|
|
style: const TextStyle(fontSize: 18),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: trailColor,
|
|
border: Border.all(
|
|
color: Colors.white,
|
|
width: 2,
|
|
),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Expanded(child: Text(contact.displayName)),
|
|
IconButton(
|
|
tooltip: l10n.exportToClipboard,
|
|
icon: const Icon(Icons.file_download_outlined),
|
|
onPressed: () =>
|
|
_exportContactTrail(context, contact),
|
|
),
|
|
],
|
|
),
|
|
subtitle: Text(
|
|
'${contact.advertHistory.length} points',
|
|
),
|
|
value: isVisible,
|
|
onChanged: (value) {
|
|
mapProvider.toggleContactPath(contact.publicKeyHex);
|
|
setModalState(() {}); // Update modal UI
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
// Close button
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(l10n.close),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _importTrail(
|
|
BuildContext context,
|
|
MapProvider mapProvider,
|
|
) async {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
try {
|
|
final importedTrail = await GpxService.importTrailFromFile();
|
|
if (importedTrail == null || !context.mounted) {
|
|
return;
|
|
}
|
|
|
|
mapProvider.replaceCurrentTrailWithImport(importedTrail);
|
|
mapProvider.clearImportedTrail();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'${l10n.locationTrail}: ${importedTrail.points.length} ${l10n.points.toLowerCase()}',
|
|
),
|
|
),
|
|
);
|
|
} catch (error) {
|
|
if (!context.mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.importFailed(error.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _exportTrail(
|
|
BuildContext context,
|
|
LocationTrail trail, {
|
|
String? customName,
|
|
}) async {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
try {
|
|
final success = await GpxService.exportTrailToFile(
|
|
trail,
|
|
customName: customName,
|
|
);
|
|
if (!success && context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.exportFailed('unable to share GPX'))),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (!context.mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.exportFailed(error.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _exportContactTrail(BuildContext context, Contact contact) async {
|
|
await _exportTrail(
|
|
context,
|
|
_buildContactTrail(contact),
|
|
customName: '${contact.displayName} Trail',
|
|
);
|
|
}
|
|
|
|
LocationTrail _buildContactTrail(Contact contact) {
|
|
final sortedHistory = [...contact.advertHistory]
|
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
|
|
return LocationTrail(
|
|
id: 'contact_${contact.publicKeyHex}',
|
|
isActive: false,
|
|
startTime: sortedHistory.first.timestamp,
|
|
endTime: sortedHistory.last.timestamp,
|
|
points: sortedHistory
|
|
.map(
|
|
(advert) => TrailPoint(
|
|
position: advert.location,
|
|
timestamp: advert.timestamp,
|
|
),
|
|
)
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
void _showClearConfirmation(
|
|
BuildContext context,
|
|
MapProvider mapProvider,
|
|
AppLocalizations l10n,
|
|
) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l10n.clearTrailQuestion),
|
|
content: Text(l10n.clearTrailConfirmation),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(l10n.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
mapProvider.clearCurrentTrail();
|
|
Navigator.pop(context); // Close dialog
|
|
Navigator.pop(context); // Close bottom sheet
|
|
},
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: Text(l10n.clearTrail),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatRow({
|
|
required IconData icon,
|
|
required String label,
|
|
required String value,
|
|
}) {
|
|
return Row(
|
|
children: [
|
|
Icon(icon, size: 18, color: Colors.blue),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _formatDistance(double meters) {
|
|
if (meters < 1000) {
|
|
return '${meters.toStringAsFixed(0)} m';
|
|
} else {
|
|
return '${(meters / 1000).toStringAsFixed(2)} km';
|
|
}
|
|
}
|
|
|
|
String _formatDuration(Duration duration) {
|
|
final hours = duration.inHours;
|
|
final minutes = duration.inMinutes.remainder(60);
|
|
final seconds = duration.inSeconds.remainder(60);
|
|
|
|
if (hours > 0) {
|
|
return '${hours}h ${minutes}m ${seconds}s';
|
|
} else if (minutes > 0) {
|
|
return '${minutes}m ${seconds}s';
|
|
} else {
|
|
return '${seconds}s';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return FloatingActionButton.small(
|
|
heroTag: 'trail_controls',
|
|
tooltip: l10n.trailControls,
|
|
onPressed: () => _showTrailMenu(context),
|
|
child: const Icon(Icons.timeline),
|
|
);
|
|
}
|
|
}
|