mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
Add GPX import export for trails
This commit is contained in:
@@ -27,6 +27,7 @@ import '../widgets/map_debug_info.dart';
|
|||||||
import '../widgets/map/compass_widget.dart';
|
import '../widgets/map/compass_widget.dart';
|
||||||
import '../widgets/map/detailed_compass_dialog.dart';
|
import '../widgets/map/detailed_compass_dialog.dart';
|
||||||
import '../widgets/map/drawing_layer.dart';
|
import '../widgets/map/drawing_layer.dart';
|
||||||
|
import '../widgets/map/drawing_toolbar.dart';
|
||||||
import '../widgets/map/location_trail_layer.dart';
|
import '../widgets/map/location_trail_layer.dart';
|
||||||
import '../widgets/map/trail_controls.dart';
|
import '../widgets/map/trail_controls.dart';
|
||||||
import '../widgets/map/map_message_overlay.dart';
|
import '../widgets/map/map_message_overlay.dart';
|
||||||
@@ -2131,6 +2132,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
right: 16,
|
right: 16,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
const DrawingToolbar(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
// Hide other buttons when in drawing mode
|
// Hide other buttons when in drawing mode
|
||||||
if (!drawingProvider.isDrawing) ...[
|
if (!drawingProvider.isDrawing) ...[
|
||||||
// Current Location - always center to GPS
|
// Current Location - always center to GPS
|
||||||
|
|||||||
@@ -318,11 +318,11 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
|
|||||||
final repeater = cluster.members.first;
|
final repeater = cluster.members.first;
|
||||||
return flutter_map.Marker(
|
return flutter_map.Marker(
|
||||||
point: cluster.center,
|
point: cluster.center,
|
||||||
width: 40,
|
width: 80,
|
||||||
height: 40,
|
height: 100,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _showRepeaterDetails(repeater),
|
onTap: () => _showRepeaterDetails(repeater),
|
||||||
child: _RepeaterMarker(color: markerColor),
|
child: _RepeaterMarker(color: markerColor, label: repeater.name),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -646,25 +646,52 @@ class _LegendRow extends StatelessWidget {
|
|||||||
|
|
||||||
class _RepeaterMarker extends StatelessWidget {
|
class _RepeaterMarker extends StatelessWidget {
|
||||||
final Color color;
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
const _RepeaterMarker({required this.color});
|
const _RepeaterMarker({required this.color, required this.label});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.18),
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
blurRadius: 8,
|
blurRadius: 4,
|
||||||
offset: const Offset(0, 3),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
child: const Icon(Icons.router, color: Colors.white, size: 18),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../models/contact.dart';
|
||||||
|
import '../../models/location_trail.dart';
|
||||||
import '../../providers/map_provider.dart';
|
import '../../providers/map_provider.dart';
|
||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
|
import '../../services/gpx_service.dart';
|
||||||
import '../../services/trail_color_service.dart';
|
import '../../services/trail_color_service.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -66,6 +69,31 @@ class TrailControls extends StatelessWidget {
|
|||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _importTrail(context, mapProvider),
|
||||||
|
icon: const 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: const Icon(Icons.file_download_outlined),
|
||||||
|
label: Text(l10n.exportToClipboard),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Trail stats
|
// Trail stats
|
||||||
if (mapProvider.currentTrail != null &&
|
if (mapProvider.currentTrail != null &&
|
||||||
mapProvider.currentTrail!.points.isNotEmpty)
|
mapProvider.currentTrail!.points.isNotEmpty)
|
||||||
@@ -104,8 +132,6 @@ class TrailControls extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Clear trail button
|
// Clear trail button
|
||||||
if (mapProvider.currentTrail != null &&
|
if (mapProvider.currentTrail != null &&
|
||||||
mapProvider.currentTrail!.points.isNotEmpty)
|
mapProvider.currentTrail!.points.isNotEmpty)
|
||||||
@@ -238,7 +264,17 @@ class TrailControls extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
title: Text(contact.displayName),
|
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(
|
subtitle: Text(
|
||||||
'${contact.advertHistory.length} points',
|
'${contact.advertHistory.length} points',
|
||||||
),
|
),
|
||||||
@@ -266,6 +302,92 @@ class TrailControls extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
void _showClearConfirmation(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
MapProvider mapProvider,
|
MapProvider mapProvider,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.0310.1+24
|
version: 2026.0310.2+25
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
|
|||||||
Reference in New Issue
Block a user