mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-16 05:40:46 +10:00
Add GPX export functionality and related UI components
This commit is contained in:
+12
-1
@@ -1343,5 +1343,16 @@
|
|||||||
"contacts_zeroHopContactAdvertSent": "Sent contact by advert.",
|
"contacts_zeroHopContactAdvertSent": "Sent contact by advert.",
|
||||||
"contacts_zeroHopContactAdvertFailed": "Failed to send contact.",
|
"contacts_zeroHopContactAdvertFailed": "Failed to send contact.",
|
||||||
"contacts_contactAdvertCopied": "Advert copied to Clipboard.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:meshcore_open/utils/gpx_export.dart';
|
||||||
import 'package:meshcore_open/widgets/elements_ui.dart';
|
import 'package:meshcore_open/widgets/elements_ui.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@@ -57,6 +58,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildDebugCard(context),
|
_buildDebugCard(context),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
_buildExportCard(connector),
|
||||||
|
const SizedBox(height: 16),
|
||||||
_buildAboutCard(context),
|
_buildAboutCard(context),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -684,6 +687,73 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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 {
|
class _RadioSettingsDialog extends StatefulWidget {
|
||||||
|
|||||||
@@ -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<ContactExport> _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<int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,9 @@ dependencies:
|
|||||||
qr_flutter: ^4.1.0 # QR code generation
|
qr_flutter: ^4.1.0 # QR code generation
|
||||||
url_launcher: ^6.3.0 # Launch URLs in system browser
|
url_launcher: ^6.3.0 # Launch URLs in system browser
|
||||||
flutter_linkify: ^6.0.0 # Auto-detect and linkify URLs in text
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user