import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'package:xml/xml.dart'; import 'package:path_provider/path_provider.dart'; import 'package:file_picker/file_picker.dart'; import 'package:share_plus/share_plus.dart'; import '../models/location_trail.dart'; /// Service for importing and exporting location trails in GPX format class GpxService { /// Export a LocationTrail to GPX 1.1 format /// Returns the GPX content as a string static String exportToGpx(LocationTrail trail, {String? customName}) { final builder = XmlBuilder(); builder.processing('xml', 'version="1.0" encoding="UTF-8"'); builder.element('gpx', nest: () { // GPX attributes builder.attribute('version', '1.1'); builder.attribute('creator', 'MeshCore SAR'); builder.attribute( 'xmlns', 'http://www.topografix.com/GPX/1/1', ); builder.attribute( 'xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance', ); builder.attribute( 'xsi:schemaLocation', 'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd', ); // Metadata section builder.element('metadata', nest: () { final name = customName ?? 'MeshCore Trail - ${_formatDateTime(trail.startTime)}'; builder.element('name', nest: () => builder.text(name)); builder.element( 'time', nest: () => builder.text(trail.startTime.toIso8601String()), ); // Add trail statistics in description final distance = trail.totalDistance; final duration = trail.duration; final description = 'Distance: ${_formatDistance(distance)}, ' 'Duration: ${_formatDuration(duration)}, ' 'Points: ${trail.points.length}'; builder.element('desc', nest: () => builder.text(description)); }); // Track section builder.element('trk', nest: () { final trackName = customName ?? 'MeshCore Trail'; builder.element('name', nest: () => builder.text(trackName)); // Track segment with all points builder.element('trkseg', nest: () { for (final point in trail.points) { builder.element('trkpt', nest: () { builder.attribute('lat', point.position.latitude.toString()); builder.attribute('lon', point.position.longitude.toString()); // Timestamp (required for proper GPX) builder.element( 'time', nest: () => builder.text(point.timestamp.toIso8601String()), ); // Elevation (optional, set to 0 if not available) builder.element('ele', nest: () => builder.text('0')); // Extensions for additional data (accuracy, speed) if (point.accuracy != null || point.speed != null) { builder.element('extensions', nest: () { if (point.accuracy != null) { builder.element( 'accuracy', nest: () => builder.text(point.accuracy.toString()), ); } if (point.speed != null) { builder.element( 'speed', nest: () => builder.text(point.speed.toString()), ); } }); } }); } }); }); }); final document = builder.buildDocument(); return document.toXmlString(pretty: true, indent: ' '); } /// Parse GPX content and return a LocationTrail /// Throws FormatException if GPX is invalid static LocationTrail importFromGpx(String gpxContent) { try { final document = XmlDocument.parse(gpxContent); final gpxElement = document.findElements('gpx').firstOrNull; if (gpxElement == null) { throw const FormatException('Invalid GPX file: Missing element'); } // Extract track name from metadata or track element (currently unused, kept for future use) // String? trackName; // final metadataName = // gpxElement.findElements('metadata').firstOrNull?.findElements('name').firstOrNull?.innerText; // final trackNameElement = gpxElement // .findElements('trk') // .firstOrNull // ?.findElements('name') // .firstOrNull // ?.innerText; // trackName = metadataName ?? trackNameElement ?? 'Imported Trail'; // Extract track points final trackPoints = []; final tracks = gpxElement.findElements('trk'); if (tracks.isEmpty) { throw const FormatException( 'Invalid GPX file: No elements found', ); } // Process first track only final track = tracks.first; final segments = track.findElements('trkseg'); for (final segment in segments) { final trkpts = segment.findElements('trkpt'); for (final trkpt in trkpts) { try { // Extract latitude and longitude (required) final latStr = trkpt.getAttribute('lat'); final lonStr = trkpt.getAttribute('lon'); if (latStr == null || lonStr == null) { debugPrint('⚠️ Skipping track point: Missing lat/lon attributes'); continue; } final lat = double.parse(latStr); final lon = double.parse(lonStr); // Extract timestamp (optional) final timeStr = trkpt.findElements('time').firstOrNull?.innerText; final timestamp = timeStr != null ? DateTime.parse(timeStr) : DateTime.now(); // Extract elevation (optional, currently unused but parsed for future use) // final eleStr = trkpt.findElements('ele').firstOrNull?.innerText; // final elevation = eleStr != null ? double.tryParse(eleStr) : null; // Extract extensions (accuracy, speed) double? accuracy; double? speed; final extensions = trkpt.findElements('extensions').firstOrNull; if (extensions != null) { final accuracyStr = extensions.findElements('accuracy').firstOrNull?.innerText; final speedStr = extensions.findElements('speed').firstOrNull?.innerText; accuracy = accuracyStr != null ? double.tryParse(accuracyStr) : null; speed = speedStr != null ? double.tryParse(speedStr) : null; } // Create trail point trackPoints.add( TrailPoint( position: LatLng(lat, lon), timestamp: timestamp, accuracy: accuracy, speed: speed, ), ); } catch (e) { debugPrint('⚠️ Error parsing track point: $e'); // Continue with next point } } } if (trackPoints.isEmpty) { throw const FormatException( 'Invalid GPX file: No valid track points found', ); } // Create LocationTrail from parsed points final startTime = trackPoints.first.timestamp; final endTime = trackPoints.last.timestamp; return LocationTrail( id: 'imported_${DateTime.now().millisecondsSinceEpoch}', points: trackPoints, startTime: startTime, endTime: endTime, isActive: false, ); } on XmlException catch (e) { throw FormatException('Invalid GPX XML: ${e.message}'); } catch (e) { throw FormatException('Failed to parse GPX file: $e'); } } /// Export trail to file and trigger system share sheet /// Returns true if successful static Future exportTrailToFile( LocationTrail trail, { String? customName, }) async { try { // Generate GPX content debugPrint('📤 Generating GPX content...'); final gpxContent = exportToGpx(trail, customName: customName); // Create filename with timestamp final timestamp = DateTime.now(); final filename = 'meshcore_trail_${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')}_${timestamp.hour.toString().padLeft(2, '0')}${timestamp.minute.toString().padLeft(2, '0')}${timestamp.second.toString().padLeft(2, '0')}.gpx'; // Save to temporary directory final tempDir = await getTemporaryDirectory(); final file = File('${tempDir.path}/$filename'); await file.writeAsString(gpxContent); debugPrint('📤 GPX file saved: ${file.path}'); debugPrint('📤 File size: ${file.lengthSync()} bytes'); // Share the file using system share sheet final result = await SharePlus.instance.share( ShareParams( files: [XFile(file.path, mimeType: 'application/gpx+xml')], subject: 'MeshCore Trail Export', text: 'MeshCore SAR location trail (${trail.points.length} points)', ), ); debugPrint('📤 Share result: ${result.status}'); return result.status == ShareResultStatus.success || result.status == ShareResultStatus.unavailable; // unavailable = user dismissed, still OK } catch (e) { debugPrint('❌ Failed to export trail: $e'); return false; } } /// Import trail from GPX file using file picker /// Returns LocationTrail if successful, null if cancelled or failed static Future importTrailFromFile() async { try { // Open file picker for GPX files debugPrint('📥 Opening file picker for GPX import...'); final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['gpx'], allowMultiple: false, ); if (result == null || result.files.isEmpty) { debugPrint('📥 Import cancelled by user'); return null; } final file = result.files.first; debugPrint('📥 Selected file: ${file.name}'); debugPrint('📥 File size: ${file.size} bytes'); // Read file content String gpxContent; if (file.path != null) { // File has path (mobile) gpxContent = await File(file.path!).readAsString(); } else if (file.bytes != null) { // File has bytes (web) gpxContent = String.fromCharCodes(file.bytes!); } else { throw Exception('Unable to read file content'); } // Parse GPX content debugPrint('📥 Parsing GPX content...'); final trail = importFromGpx(gpxContent); debugPrint( '✅ Successfully imported trail: ${trail.points.length} points', ); return trail; } catch (e) { debugPrint('❌ Failed to import trail: $e'); rethrow; } } // Helper: Format distance for display static String _formatDistance(double meters) { if (meters < 1000) { return '${meters.toStringAsFixed(0)} m'; } else { return '${(meters / 1000).toStringAsFixed(2)} km'; } } // Helper: Format duration for display static 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'; } } // Helper: Format DateTime for filename static String _formatDateTime(DateTime dt) { return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}'; } }