From b415b63b4bb894c68cb2fa782a674aad2811d48f Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 10 Mar 2026 13:52:37 +0100 Subject: [PATCH] Add GPX import export for trails --- lib/screens/map_tab.dart | 3 + lib/screens/repeaters_map_screen.dart | 61 ++++++++---- lib/widgets/map/trail_controls.dart | 128 +++++++++++++++++++++++++- pubspec.yaml | 2 +- 4 files changed, 173 insertions(+), 21 deletions(-) diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 7a9eaa1..c587810 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -27,6 +27,7 @@ import '../widgets/map_debug_info.dart'; import '../widgets/map/compass_widget.dart'; import '../widgets/map/detailed_compass_dialog.dart'; import '../widgets/map/drawing_layer.dart'; +import '../widgets/map/drawing_toolbar.dart'; import '../widgets/map/location_trail_layer.dart'; import '../widgets/map/trail_controls.dart'; import '../widgets/map/map_message_overlay.dart'; @@ -2131,6 +2132,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { right: 16, child: Column( children: [ + const DrawingToolbar(), + const SizedBox(height: 8), // Hide other buttons when in drawing mode if (!drawingProvider.isDrawing) ...[ // Current Location - always center to GPS diff --git a/lib/screens/repeaters_map_screen.dart b/lib/screens/repeaters_map_screen.dart index 3c49f8e..c00362e 100644 --- a/lib/screens/repeaters_map_screen.dart +++ b/lib/screens/repeaters_map_screen.dart @@ -318,11 +318,11 @@ class _RepeatersMapScreenState extends State { final repeater = cluster.members.first; return flutter_map.Marker( point: cluster.center, - width: 40, - height: 40, + width: 80, + height: 100, child: GestureDetector( 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 { final Color color; + final String label; - const _RepeaterMarker({required this.color}); + const _RepeaterMarker({required this.color, required this.label}); @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.18), - blurRadius: 8, - offset: const Offset(0, 3), + 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), + ), + ], ), - ], - ), - child: const Icon(Icons.router, color: Colors.white, size: 18), + 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, + ), + ), + ], ); } } diff --git a/lib/widgets/map/trail_controls.dart b/lib/widgets/map/trail_controls.dart index 8730f57..6f7bca2 100644 --- a/lib/widgets/map/trail_controls.dart +++ b/lib/widgets/map/trail_controls.dart @@ -1,7 +1,10 @@ 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'; @@ -66,6 +69,31 @@ class TrailControls extends StatelessWidget { const Divider(), 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 if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty) @@ -104,8 +132,6 @@ class TrailControls extends StatelessWidget { ), ), - const SizedBox(height: 16), - // Clear trail button if (mapProvider.currentTrail != null && 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( '${contact.advertHistory.length} points', ), @@ -266,6 +302,92 @@ class TrailControls extends StatelessWidget { ); } + Future _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 _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 _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, diff --git a/pubspec.yaml b/pubspec.yaml index 400ab62..93e578c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 2026.0310.1+24 +version: 2026.0310.2+25 environment: sdk: ^3.9.2