From 8b1228bf8d8d05f558f897769f9cee830e5d4995 Mon Sep 17 00:00:00 2001 From: Winston Lowe Date: Thu, 5 Feb 2026 13:38:49 -0800 Subject: [PATCH] Add GPX export functionality and related UI components --- lib/l10n/app_en.arb | 13 ++- lib/screens/settings_screen.dart | 70 +++++++++++++ lib/utils/gpx_export.dart | 165 +++++++++++++++++++++++++++++++ pubspec.yaml | 3 + 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 lib/utils/gpx_export.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ee5cf7d..8f501e0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1343,5 +1343,16 @@ "contacts_zeroHopContactAdvertSent": "Sent contact by advert.", "contacts_zeroHopContactAdvertFailed": "Failed to send contact.", "contacts_contactAdvertCopied": "Advert copied to Clipboard.", - "contacts_contactAdvertCopyFailed": "Copying advert to Clipboard failed." + "contacts_contactAdvertCopyFailed": "Copying advert to Clipboard failed.", + + "settings_gpxExportRepeaters": "Export repeaters to GPX", + "settings_gpxExportRepeatersSubtitle": "Exports repeaters with a location to GPX file.", + "settings_gpxExportContacts": "Export contacts to GPX", + "settings_gpxExportContactsSubtitle": "Exports chat contacts with a location to GPX file.", + "settings_gpxExportAll": "Export all to GPX", + "settings_gpxExportAllSubtitle": "Exports all contacts with a location to GPX file.", + "settings_gpxExportSuccess": "Successfully exported GPX file.", + "settings_gpxExportNoContacts": "No contacts to export.", + "settings_gpxExportNotAvailable": "Not supported on your device/OS", + "settings_gpxExportError": "There was an error when exporting." } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 415d508..cbcbc0d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:meshcore_open/utils/gpx_export.dart'; import 'package:meshcore_open/widgets/elements_ui.dart'; import 'package:provider/provider.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -57,6 +58,8 @@ class _SettingsScreenState extends State { const SizedBox(height: 16), _buildDebugCard(context), const SizedBox(height: 16), + _buildExportCard(connector), + const SizedBox(height: 16), _buildAboutCard(context), ], ); @@ -684,6 +687,73 @@ class _SettingsScreenState extends State { ], ); } + + _gpxExport(GpxExport exporter) async { + final l10n = context.l10n; + final result = await exporter.exportGPX(); + // Implement GPX export functionality here + switch (result) { + case GpxExportSuccess: + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportSuccess))); + case GpxExportNoContacts: + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportNoContacts))); + case GpxExportNotAvailable: + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportNotAvailable))); + case GpxExportFailed: + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportError))); + } + } + + _buildExportCard(MeshCoreConnector connector) { + final l10n = context.l10n; + return Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.download_outlined), + title: Text(l10n.settings_gpxExportRepeaters), + subtitle: Text(l10n.settings_gpxExportRepeatersSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final exporter = GpxExport(connector); + exporter.addRepeaters(); + _gpxExport(exporter); + }, + ), + ListTile( + leading: const Icon(Icons.download_outlined), + title: Text(l10n.settings_gpxExportContacts), + subtitle: Text(l10n.settings_gpxExportContactsSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final exporter = GpxExport(connector); + exporter.addContacts(); + _gpxExport(exporter); + }, + ), + ListTile( + leading: const Icon(Icons.download_outlined), + title: Text(l10n.settings_gpxExportAll), + subtitle: Text(l10n.settings_gpxExportAllSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final exporter = GpxExport(connector); + exporter.addAll(); + _gpxExport(exporter); + }, + ), + ], + ), + ); + } } class _RadioSettingsDialog extends StatefulWidget { diff --git a/lib/utils/gpx_export.dart b/lib/utils/gpx_export.dart new file mode 100644 index 0000000..ae1c128 --- /dev/null +++ b/lib/utils/gpx_export.dart @@ -0,0 +1,165 @@ +import 'package:flutter/foundation.dart'; +import 'package:gpx/gpx.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; + +import 'package:share_plus/share_plus.dart'; + +class ContactExport { + final String name; + final double lat; + final double lon; + final String desc; + final double? ele; + + ContactExport({ + required this.name, + required this.lat, + required this.lon, + required this.desc, + this.ele, + }); +} + +const int GpxExportFailed = -1; +const int GpxExportSuccess = 1; +const int GpxExportNoContacts = 2; +const int GpxExportCancelled = 3; +const int GpxExportNotAvailable = 4; + +class GpxExport { + MeshCoreConnector _connector; + List _contacts = []; + + GpxExport(this._connector); + + void _addContact(String name, double lat, double lon, String desc, [double? ele]) { + _contacts.add(ContactExport( + name: name.trim(), + lat: lat, + lon: lon, + desc: desc.trim(), + ele: ele, + )); + } + + void addRepeaters() { + final contacts = _connector.contacts; + final repeaters = contacts.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom).toList(); + for (var repeater in repeaters) { + if (repeater.latitude == null || repeater.longitude == null) { + continue; + } + _addContact( + repeater.name, + repeater.latitude ?? 0.0, + repeater.longitude ?? 0.0, + "Type: ${repeater.typeLabel}\nPublic Key: ${repeater.publicKeyHex}", + ); + } + } + + void addContacts() { + final contacts = _connector.contacts; + final repeaters = contacts.where((c) => c.type == advTypeChat).toList(); + for (var repeater in repeaters) { + if (repeater.latitude == null || repeater.longitude == null) { + continue; + } + _addContact( + repeater.name, + repeater.latitude ?? 0.0, + repeater.longitude ?? 0.0, + "Type: ${repeater.typeLabel}\nPublic Key: ${repeater.publicKeyHex}", + ); + } + } + + void addAll() { + final contacts = _connector.contacts; + for (var repeater in contacts.toList()) { + if (repeater.latitude == null || repeater.longitude == null) { + continue; + } + _addContact( + repeater.name, + repeater.latitude ?? 0.0, + repeater.longitude ?? 0.0, + "Type: ${repeater.typeLabel}\nPublic Key: ${repeater.publicKeyHex}", + ); + } + } + + Future exportGPX() async { + if (_contacts.isEmpty) { + debugPrint("No repeaters to export – nothing to share."); + return GpxExportNoContacts; + } + + try { + // 1. Build GPX content (your existing logic – unchanged here) + final gpx = Gpx() + ..version = '1.1' + ..creator = 'meshcore-open Repeater Exporter' + ..metadata = Metadata( + name: 'Meshcore Repeaters', + desc: 'Repeater & room locations exported from meshcore-open', + time: DateTime.now().toUtc(), + ); + + gpx.wpts = _contacts.map((c) => Wpt( + lat: c.lat, + lon: c.lon, + ele: c.ele, + name: c.name, + desc: c.desc, + )).toList(); + + final xml = GpxWriter().asString(gpx, pretty: true); + + // 2. Save to file + final dir = await getApplicationDocumentsDirectory(); + final timestamp = DateTime.now().toUtc().toIso8601String() + .replaceAll(':', '-') + .replaceAll('.', '-') + .split('T') + .join('_'); + final path = '${dir.path}/meshcore_repeaters_$timestamp.gpx'; + + final file = File(path); + await file.writeAsString(xml); + + // 3. Modern share call (2025+ style) + final result = await SharePlus.instance.share( + ShareParams( + text: 'Repeater locations exported from meshcore-open app as GPX file.', + subject: 'Meshcore Repeaters GPX Export', + files: [XFile(path)], + // Optional: sharePositionOrigin: ... (if you want iPad popover positioning) + ), + ); + + // 4. Handle result + switch (result.status) { + case ShareResultStatus.success: + debugPrint('Share successful – user completed the action.'); + return GpxExportSuccess; + case ShareResultStatus.dismissed: + debugPrint('Share sheet was dismissed / cancelled by user.'); + return GpxExportCancelled; + case ShareResultStatus.unavailable: + debugPrint('Sharing is not available on this platform / context.'); + return GpxExportNotAvailable; + } + + // Optional cleanup (uncomment if you don't want to keep the file) + // await file.delete(); + } catch (e, stack) { + debugPrint('Export or share failed: $e\n$stack'); + // → here you could show a SnackBar / AlertDialog in real UI code + } + return GpxExportFailed; + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 8b1415f..6312ee3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,9 @@ dependencies: qr_flutter: ^4.1.0 # QR code generation url_launcher: ^6.3.0 # Launch URLs in system browser flutter_linkify: ^6.0.0 # Auto-detect and linkify URLs in text + gpx: ^2.3.0 + path_provider: ^2.1.5 + share_plus: ^12.0.1 dev_dependencies: flutter_test: