From e7fe700fee7f48266af36efa958d4d1932a852b9 Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 28 Oct 2025 11:04:09 +0100 Subject: [PATCH] feat: Add GPX import/export functionality and enhance trail management - Implemented GPX export and import features in GpxService for location trails. - Added UI controls for exporting and importing trails in the TrailControls widget. - Introduced localization strings for GPX operations in Italian and Slovenian. - Enhanced MapProvider to manage imported trails and toggle visibility for contact trails. - Updated trail color management with TrailColorService for consistent color assignment based on contact roles. - Improved UI for displaying contact trails with toggle options for individual and all contact trails. - Refactored detailed compass dialog to remove unnecessary path toggle button for contacts. --- lib/l10n/app_de.arb | 105 ++++++ lib/l10n/app_en.arb | 105 ++++++ lib/l10n/app_es.arb | 105 ++++++ lib/l10n/app_fr.arb | 105 ++++++ lib/l10n/app_hr.arb | 32 ++ lib/l10n/app_it.arb | 105 ++++++ lib/l10n/app_localizations.dart | 96 ++++++ lib/l10n/app_localizations_de.dart | 59 ++++ lib/l10n/app_localizations_en.dart | 59 ++++ lib/l10n/app_localizations_es.dart | 59 ++++ lib/l10n/app_localizations_fr.dart | 59 ++++ lib/l10n/app_localizations_hr.dart | 59 ++++ lib/l10n/app_localizations_it.dart | 59 ++++ lib/l10n/app_localizations_sl.dart | 58 ++++ lib/l10n/app_sl.arb | 32 ++ lib/providers/map_provider.dart | 57 ++++ lib/screens/map_tab.dart | 46 ++- lib/services/gpx_service.dart | 339 +++++++++++++++++++ lib/services/trail_color_service.dart | 236 +++++++++++++ lib/widgets/map/detailed_compass_dialog.dart | 21 +- lib/widgets/map/trail_controls.dart | 199 ++++++++++- pubspec.lock | 2 +- pubspec.yaml | 3 + 23 files changed, 1967 insertions(+), 33 deletions(-) create mode 100644 lib/services/gpx_service.dart create mode 100644 lib/services/trail_color_service.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 587839b..b13cb33 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2315,6 +2315,111 @@ "description": "Tooltip für Verlaufssteuerung" }, + "exportTrailToGpx": "Verlauf als GPX exportieren", + "@exportTrailToGpx": { + "description": "Beschriftung der Schaltfläche zum Exportieren des Verlaufs in eine GPX-Datei" + }, + + "importTrailFromGpx": "Verlauf aus GPX importieren", + "@importTrailFromGpx": { + "description": "Beschriftung der Schaltfläche zum Importieren des Verlaufs aus einer GPX-Datei" + }, + + "trailExportedSuccessfully": "Verlauf erfolgreich exportiert!", + "@trailExportedSuccessfully": { + "description": "Erfolgsmeldung beim Exportieren des Verlaufs" + }, + + "failedToExportTrail": "Exportieren des Verlaufs fehlgeschlagen", + "@failedToExportTrail": { + "description": "Fehlermeldung beim Exportieren des Verlaufs" + }, + + "failedToImportTrail": "Importieren des Verlaufs fehlgeschlagen: {error}", + "@failedToImportTrail": { + "description": "Fehlermeldung beim Importieren des Verlaufs", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "importTrail": "Verlauf importieren", + "@importTrail": { + "description": "Titel des Dialogs zum Importieren des Verlaufs" + }, + + "importTrailQuestion": "Verlauf mit {pointCount} Punkten importieren?\n\nSie können Ihren aktuellen Verlauf ersetzen oder daneben anzeigen.", + "@importTrailQuestion": { + "description": "Inhalt des Bestätigungsdialogs zum Importieren des Verlaufs", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "viewAlongside": "Daneben anzeigen", + "@viewAlongside": { + "description": "Schaltfläche zum Importieren des Verlaufs neben dem aktuellen Verlauf" + }, + + "replaceCurrent": "Aktuellen ersetzen", + "@replaceCurrent": { + "description": "Schaltfläche zum Ersetzen des aktuellen Verlaufs durch importierten Verlauf" + }, + + "trailImported": "Verlauf importiert! ({pointCount} Punkte)", + "@trailImported": { + "description": "Erfolgsmeldung beim Importieren des Verlaufs", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "trailReplaced": "Verlauf ersetzt! ({pointCount} Punkte)", + "@trailReplaced": { + "description": "Erfolgsmeldung beim Ersetzen des Verlaufs", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "contactTrails": "Kontaktverläufe", + "@contactTrails": { + "description": "Überschrift des Bereichs für Kontaktverläufe" + }, + + "showAllContactTrails": "Alle Kontaktverläufe anzeigen", + "@showAllContactTrails": { + "description": "Beschriftung zum Umschalten der Anzeige aller Kontaktverläufe" + }, + + "noContactsWithLocationHistory": "Keine Kontakte mit Standortverlauf", + "@noContactsWithLocationHistory": { + "description": "Untertitel, wenn keine Kontakte mit Verläufen vorhanden sind" + }, + + "showingTrailsForContacts": "Verläufe für {count} Kontakte anzeigen", + "@showingTrailsForContacts": { + "description": "Untertitel mit Anzahl der Kontakte mit Verläufen", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "individualContactTrails": "Einzelne Kontaktverläufe", + "@individualContactTrails": { + "description": "Titel des Erweiterungselements für einzelne Kontaktverläufe" + }, + "deviceInformation": "Geräteinformationen", "@deviceInformation": { "description": "Überschrift des Geräteinformationen-Bereichs" diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 72f5665..0f85723 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2375,6 +2375,111 @@ "description": "Trail controls tooltip" }, + "exportTrailToGpx": "Export Trail to GPX", + "@exportTrailToGpx": { + "description": "Button label to export trail to GPX file" + }, + + "importTrailFromGpx": "Import Trail from GPX", + "@importTrailFromGpx": { + "description": "Button label to import trail from GPX file" + }, + + "trailExportedSuccessfully": "Trail exported successfully!", + "@trailExportedSuccessfully": { + "description": "Success message when trail is exported" + }, + + "failedToExportTrail": "Failed to export trail", + "@failedToExportTrail": { + "description": "Error message when trail export fails" + }, + + "failedToImportTrail": "Failed to import trail: {error}", + "@failedToImportTrail": { + "description": "Error message when trail import fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "importTrail": "Import Trail", + "@importTrail": { + "description": "Import trail dialog title" + }, + + "importTrailQuestion": "Import trail with {pointCount} points?\n\nYou can replace your current trail or view it alongside.", + "@importTrailQuestion": { + "description": "Import trail confirmation dialog content", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "viewAlongside": "View Alongside", + "@viewAlongside": { + "description": "Button to import trail alongside current trail" + }, + + "replaceCurrent": "Replace Current", + "@replaceCurrent": { + "description": "Button to replace current trail with imported trail" + }, + + "trailImported": "Trail imported! ({pointCount} points)", + "@trailImported": { + "description": "Success message when trail is imported", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "trailReplaced": "Trail replaced! ({pointCount} points)", + "@trailReplaced": { + "description": "Success message when trail is replaced", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "contactTrails": "Contact Trails", + "@contactTrails": { + "description": "Contact trails section header" + }, + + "showAllContactTrails": "Show All Contact Trails", + "@showAllContactTrails": { + "description": "Toggle label to show all contact trails" + }, + + "noContactsWithLocationHistory": "No contacts with location history", + "@noContactsWithLocationHistory": { + "description": "Subtitle when no contacts have trails" + }, + + "showingTrailsForContacts": "Showing trails for {count} contacts", + "@showingTrailsForContacts": { + "description": "Subtitle showing number of contacts with trails", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "individualContactTrails": "Individual Contact Trails", + "@individualContactTrails": { + "description": "Expansion tile title for individual contact trails" + }, + "deviceInformation": "Device Information", "@deviceInformation": { "description": "Device information section header" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index cdf92d6..0dd57cf 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2310,6 +2310,111 @@ "description": "Tooltip de controles del rastro" }, + "exportTrailToGpx": "Exportar rastro a GPX", + "@exportTrailToGpx": { + "description": "Etiqueta del botón para exportar el rastro a archivo GPX" + }, + + "importTrailFromGpx": "Importar rastro desde GPX", + "@importTrailFromGpx": { + "description": "Etiqueta del botón para importar el rastro desde archivo GPX" + }, + + "trailExportedSuccessfully": "¡Rastro exportado exitosamente!", + "@trailExportedSuccessfully": { + "description": "Mensaje de éxito cuando se exporta el rastro" + }, + + "failedToExportTrail": "Error al exportar el rastro", + "@failedToExportTrail": { + "description": "Mensaje de error cuando falla la exportación del rastro" + }, + + "failedToImportTrail": "Error al importar el rastro: {error}", + "@failedToImportTrail": { + "description": "Mensaje de error cuando falla la importación del rastro", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "importTrail": "Importar rastro", + "@importTrail": { + "description": "Título del diálogo de importación de rastro" + }, + + "importTrailQuestion": "¿Importar rastro con {pointCount} puntos?\n\nPuede reemplazar su rastro actual o verlo junto a él.", + "@importTrailQuestion": { + "description": "Contenido del diálogo de confirmación de importación de rastro", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "viewAlongside": "Ver junto", + "@viewAlongside": { + "description": "Botón para importar el rastro junto al rastro actual" + }, + + "replaceCurrent": "Reemplazar actual", + "@replaceCurrent": { + "description": "Botón para reemplazar el rastro actual con el rastro importado" + }, + + "trailImported": "¡Rastro importado! ({pointCount} puntos)", + "@trailImported": { + "description": "Mensaje de éxito cuando se importa el rastro", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "trailReplaced": "¡Rastro reemplazado! ({pointCount} puntos)", + "@trailReplaced": { + "description": "Mensaje de éxito cuando se reemplaza el rastro", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "contactTrails": "Rastros de contactos", + "@contactTrails": { + "description": "Encabezado de la sección de rastros de contactos" + }, + + "showAllContactTrails": "Mostrar todos los rastros de contactos", + "@showAllContactTrails": { + "description": "Etiqueta del interruptor para mostrar todos los rastros de contactos" + }, + + "noContactsWithLocationHistory": "No hay contactos con historial de ubicación", + "@noContactsWithLocationHistory": { + "description": "Subtítulo cuando no hay contactos con rastros" + }, + + "showingTrailsForContacts": "Mostrando rastros para {count} contactos", + "@showingTrailsForContacts": { + "description": "Subtítulo que muestra el número de contactos con rastros", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "individualContactTrails": "Rastros individuales de contactos", + "@individualContactTrails": { + "description": "Título del elemento expandible para rastros individuales de contactos" + }, + "deviceInformation": "Información del dispositivo", "@deviceInformation": { "description": "Encabezado de la sección de información del dispositivo" diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6561315..27744db 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2315,6 +2315,111 @@ "description": "Info-bulle des contrôles de la trace" }, + "exportTrailToGpx": "Exporter la trace vers GPX", + "@exportTrailToGpx": { + "description": "Étiquette du bouton pour exporter la trace vers un fichier GPX" + }, + + "importTrailFromGpx": "Importer la trace depuis GPX", + "@importTrailFromGpx": { + "description": "Étiquette du bouton pour importer la trace depuis un fichier GPX" + }, + + "trailExportedSuccessfully": "Trace exportée avec succès!", + "@trailExportedSuccessfully": { + "description": "Message de réussite lors de l'exportation de la trace" + }, + + "failedToExportTrail": "Échec de l'exportation de la trace", + "@failedToExportTrail": { + "description": "Message d'erreur lors de l'échec de l'exportation de la trace" + }, + + "failedToImportTrail": "Échec de l'importation de la trace: {error}", + "@failedToImportTrail": { + "description": "Message d'erreur lors de l'échec de l'importation de la trace", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "importTrail": "Importer la trace", + "@importTrail": { + "description": "Titre du dialogue d'importation de trace" + }, + + "importTrailQuestion": "Importer la trace avec {pointCount} points?\n\nVous pouvez remplacer votre trace actuelle ou l'afficher à côté.", + "@importTrailQuestion": { + "description": "Contenu du dialogue de confirmation d'importation de trace", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "viewAlongside": "Afficher à côté", + "@viewAlongside": { + "description": "Bouton pour importer la trace à côté de la trace actuelle" + }, + + "replaceCurrent": "Remplacer l'actuel", + "@replaceCurrent": { + "description": "Bouton pour remplacer la trace actuelle par la trace importée" + }, + + "trailImported": "Trace importée! ({pointCount} points)", + "@trailImported": { + "description": "Message de réussite lors de l'importation de la trace", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "trailReplaced": "Trace remplacée! ({pointCount} points)", + "@trailReplaced": { + "description": "Message de réussite lors du remplacement de la trace", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "contactTrails": "Traces des contacts", + "@contactTrails": { + "description": "En-tête de section des traces des contacts" + }, + + "showAllContactTrails": "Afficher toutes les traces des contacts", + "@showAllContactTrails": { + "description": "Étiquette du commutateur pour afficher toutes les traces des contacts" + }, + + "noContactsWithLocationHistory": "Aucun contact avec historique de localisation", + "@noContactsWithLocationHistory": { + "description": "Sous-titre lorsqu'il n'y a pas de contacts avec des traces" + }, + + "showingTrailsForContacts": "Affichage des traces pour {count} contacts", + "@showingTrailsForContacts": { + "description": "Sous-titre montrant le nombre de contacts avec des traces", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "individualContactTrails": "Traces individuelles des contacts", + "@individualContactTrails": { + "description": "Titre de l'élément extensible pour les traces individuelles des contacts" + }, + "deviceInformation": "Informations sur l'appareil", "@deviceInformation": { "description": "En-tête de section des informations sur l'appareil" diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb index 5417417..6a44a94 100644 --- a/lib/l10n/app_hr.arb +++ b/lib/l10n/app_hr.arb @@ -787,6 +787,38 @@ "trailControls": "Upravljanje putanjom", + "exportTrailToGpx": "Izvezi putanju u GPX", + + "importTrailFromGpx": "Uvezi putanju iz GPX", + + "trailExportedSuccessfully": "Putanja uspješno izvezena!", + + "failedToExportTrail": "Izvoz putanje nije uspio", + + "failedToImportTrail": "Uvoz putanje nije uspio: {error}", + + "importTrail": "Uvezi putanju", + + "importTrailQuestion": "Uvezi putanju s {pointCount} točaka?\n\nMožete zamijeniti trenutnu putanju ili je prikazati zajedno.", + + "viewAlongside": "Prikaži zajedno", + + "replaceCurrent": "Zamijeni trenutnu", + + "trailImported": "Putanja uvezena! ({pointCount} točaka)", + + "trailReplaced": "Putanja zamijenjena! ({pointCount} točaka)", + + "contactTrails": "Putanje kontakata", + + "showAllContactTrails": "Prikaži sve putanje kontakata", + + "noContactsWithLocationHistory": "Nema kontakata s poviješću lokacije", + + "showingTrailsForContacts": "Prikazujem putanje za {count} kontakata", + + "individualContactTrails": "Pojedinačne putanje kontakata", + "deviceInformation": "Informacije o uređaju", "bleName": "BLE naziv", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 1240e0f..d005f5e 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2315,6 +2315,111 @@ "description": "Tooltip controlli traccia" }, + "exportTrailToGpx": "Esporta traccia in GPX", + "@exportTrailToGpx": { + "description": "Etichetta del pulsante per esportare la traccia in file GPX" + }, + + "importTrailFromGpx": "Importa traccia da GPX", + "@importTrailFromGpx": { + "description": "Etichetta del pulsante per importare la traccia da file GPX" + }, + + "trailExportedSuccessfully": "Traccia esportata con successo!", + "@trailExportedSuccessfully": { + "description": "Messaggio di successo quando la traccia viene esportata" + }, + + "failedToExportTrail": "Esportazione traccia fallita", + "@failedToExportTrail": { + "description": "Messaggio di errore quando l'esportazione della traccia fallisce" + }, + + "failedToImportTrail": "Importazione traccia fallita: {error}", + "@failedToImportTrail": { + "description": "Messaggio di errore quando l'importazione della traccia fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "importTrail": "Importa traccia", + "@importTrail": { + "description": "Titolo del dialogo di importazione traccia" + }, + + "importTrailQuestion": "Importare traccia con {pointCount} punti?\n\nPuoi sostituire la tua traccia attuale o visualizzarla affiancata.", + "@importTrailQuestion": { + "description": "Contenuto del dialogo di conferma importazione traccia", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "viewAlongside": "Visualizza affiancata", + "@viewAlongside": { + "description": "Pulsante per importare la traccia affiancata alla traccia attuale" + }, + + "replaceCurrent": "Sostituisci attuale", + "@replaceCurrent": { + "description": "Pulsante per sostituire la traccia attuale con la traccia importata" + }, + + "trailImported": "Traccia importata! ({pointCount} punti)", + "@trailImported": { + "description": "Messaggio di successo quando la traccia viene importata", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "trailReplaced": "Traccia sostituita! ({pointCount} punti)", + "@trailReplaced": { + "description": "Messaggio di successo quando la traccia viene sostituita", + "placeholders": { + "pointCount": { + "type": "int" + } + } + }, + + "contactTrails": "Tracce contatti", + "@contactTrails": { + "description": "Intestazione sezione tracce contatti" + }, + + "showAllContactTrails": "Mostra tutte le tracce dei contatti", + "@showAllContactTrails": { + "description": "Etichetta dell'interruttore per mostrare tutte le tracce dei contatti" + }, + + "noContactsWithLocationHistory": "Nessun contatto con cronologia posizione", + "@noContactsWithLocationHistory": { + "description": "Sottotitolo quando non ci sono contatti con tracce" + }, + + "showingTrailsForContacts": "Visualizzazione tracce per {count} contatti", + "@showingTrailsForContacts": { + "description": "Sottotitolo che mostra il numero di contatti con tracce", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "individualContactTrails": "Tracce individuali dei contatti", + "@individualContactTrails": { + "description": "Titolo dell'elemento espandibile per le tracce individuali dei contatti" + }, + "deviceInformation": "Informazioni Dispositivo", "@deviceInformation": { "description": "Intestazione sezione informazioni dispositivo" diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 166466a..a373d3e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2517,6 +2517,102 @@ abstract class AppLocalizations { /// **'Trail Controls'** String get trailControls; + /// Button label to export trail to GPX file + /// + /// In en, this message translates to: + /// **'Export Trail to GPX'** + String get exportTrailToGpx; + + /// Button label to import trail from GPX file + /// + /// In en, this message translates to: + /// **'Import Trail from GPX'** + String get importTrailFromGpx; + + /// Success message when trail is exported + /// + /// In en, this message translates to: + /// **'Trail exported successfully!'** + String get trailExportedSuccessfully; + + /// Error message when trail export fails + /// + /// In en, this message translates to: + /// **'Failed to export trail'** + String get failedToExportTrail; + + /// Error message when trail import fails + /// + /// In en, this message translates to: + /// **'Failed to import trail: {error}'** + String failedToImportTrail(String error); + + /// Import trail dialog title + /// + /// In en, this message translates to: + /// **'Import Trail'** + String get importTrail; + + /// Import trail confirmation dialog content + /// + /// In en, this message translates to: + /// **'Import trail with {pointCount} points?\n\nYou can replace your current trail or view it alongside.'** + String importTrailQuestion(int pointCount); + + /// Button to import trail alongside current trail + /// + /// In en, this message translates to: + /// **'View Alongside'** + String get viewAlongside; + + /// Button to replace current trail with imported trail + /// + /// In en, this message translates to: + /// **'Replace Current'** + String get replaceCurrent; + + /// Success message when trail is imported + /// + /// In en, this message translates to: + /// **'Trail imported! ({pointCount} points)'** + String trailImported(int pointCount); + + /// Success message when trail is replaced + /// + /// In en, this message translates to: + /// **'Trail replaced! ({pointCount} points)'** + String trailReplaced(int pointCount); + + /// Contact trails section header + /// + /// In en, this message translates to: + /// **'Contact Trails'** + String get contactTrails; + + /// Toggle label to show all contact trails + /// + /// In en, this message translates to: + /// **'Show All Contact Trails'** + String get showAllContactTrails; + + /// Subtitle when no contacts have trails + /// + /// In en, this message translates to: + /// **'No contacts with location history'** + String get noContactsWithLocationHistory; + + /// Subtitle showing number of contacts with trails + /// + /// In en, this message translates to: + /// **'Showing trails for {count} contacts'** + String showingTrailsForContacts(int count); + + /// Expansion tile title for individual contact trails + /// + /// In en, this message translates to: + /// **'Individual Contact Trails'** + String get individualContactTrails; + /// Device information section header /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 50902db..55f26aa 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1402,6 +1402,65 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trailControls => 'Verlaufssteuerung'; + @override + String get exportTrailToGpx => 'Verlauf als GPX exportieren'; + + @override + String get importTrailFromGpx => 'Verlauf aus GPX importieren'; + + @override + String get trailExportedSuccessfully => 'Verlauf erfolgreich exportiert!'; + + @override + String get failedToExportTrail => 'Exportieren des Verlaufs fehlgeschlagen'; + + @override + String failedToImportTrail(String error) { + return 'Importieren des Verlaufs fehlgeschlagen: $error'; + } + + @override + String get importTrail => 'Verlauf importieren'; + + @override + String importTrailQuestion(int pointCount) { + return 'Verlauf mit $pointCount Punkten importieren?\n\nSie können Ihren aktuellen Verlauf ersetzen oder daneben anzeigen.'; + } + + @override + String get viewAlongside => 'Daneben anzeigen'; + + @override + String get replaceCurrent => 'Aktuellen ersetzen'; + + @override + String trailImported(int pointCount) { + return 'Verlauf importiert! ($pointCount Punkte)'; + } + + @override + String trailReplaced(int pointCount) { + return 'Verlauf ersetzt! ($pointCount Punkte)'; + } + + @override + String get contactTrails => 'Kontaktverläufe'; + + @override + String get showAllContactTrails => 'Alle Kontaktverläufe anzeigen'; + + @override + String get noContactsWithLocationHistory => + 'Keine Kontakte mit Standortverlauf'; + + @override + String showingTrailsForContacts(int count) { + return 'Verläufe für $count Kontakte anzeigen'; + } + + @override + String get individualContactTrails => 'Einzelne Kontaktverläufe'; + @override String get deviceInformation => 'Geräteinformationen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0b45b96..2129c27 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1390,6 +1390,65 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trailControls => 'Trail Controls'; + @override + String get exportTrailToGpx => 'Export Trail to GPX'; + + @override + String get importTrailFromGpx => 'Import Trail from GPX'; + + @override + String get trailExportedSuccessfully => 'Trail exported successfully!'; + + @override + String get failedToExportTrail => 'Failed to export trail'; + + @override + String failedToImportTrail(String error) { + return 'Failed to import trail: $error'; + } + + @override + String get importTrail => 'Import Trail'; + + @override + String importTrailQuestion(int pointCount) { + return 'Import trail with $pointCount points?\n\nYou can replace your current trail or view it alongside.'; + } + + @override + String get viewAlongside => 'View Alongside'; + + @override + String get replaceCurrent => 'Replace Current'; + + @override + String trailImported(int pointCount) { + return 'Trail imported! ($pointCount points)'; + } + + @override + String trailReplaced(int pointCount) { + return 'Trail replaced! ($pointCount points)'; + } + + @override + String get contactTrails => 'Contact Trails'; + + @override + String get showAllContactTrails => 'Show All Contact Trails'; + + @override + String get noContactsWithLocationHistory => + 'No contacts with location history'; + + @override + String showingTrailsForContacts(int count) { + return 'Showing trails for $count contacts'; + } + + @override + String get individualContactTrails => 'Individual Contact Trails'; + @override String get deviceInformation => 'Device Information'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 8e7c104..1617ffe 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1401,6 +1401,65 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trailControls => 'Controles del rastro'; + @override + String get exportTrailToGpx => 'Exportar rastro a GPX'; + + @override + String get importTrailFromGpx => 'Importar rastro desde GPX'; + + @override + String get trailExportedSuccessfully => '¡Rastro exportado exitosamente!'; + + @override + String get failedToExportTrail => 'Error al exportar el rastro'; + + @override + String failedToImportTrail(String error) { + return 'Error al importar el rastro: $error'; + } + + @override + String get importTrail => 'Importar rastro'; + + @override + String importTrailQuestion(int pointCount) { + return '¿Importar rastro con $pointCount puntos?\n\nPuede reemplazar su rastro actual o verlo junto a él.'; + } + + @override + String get viewAlongside => 'Ver junto'; + + @override + String get replaceCurrent => 'Reemplazar actual'; + + @override + String trailImported(int pointCount) { + return '¡Rastro importado! ($pointCount puntos)'; + } + + @override + String trailReplaced(int pointCount) { + return '¡Rastro reemplazado! ($pointCount puntos)'; + } + + @override + String get contactTrails => 'Rastros de contactos'; + + @override + String get showAllContactTrails => 'Mostrar todos los rastros de contactos'; + + @override + String get noContactsWithLocationHistory => + 'No hay contactos con historial de ubicación'; + + @override + String showingTrailsForContacts(int count) { + return 'Mostrando rastros para $count contactos'; + } + + @override + String get individualContactTrails => 'Rastros individuales de contactos'; + @override String get deviceInformation => 'Información del dispositivo'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 1093c7c..5b3aefb 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1407,6 +1407,65 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trailControls => 'Contrôles de la trace'; + @override + String get exportTrailToGpx => 'Exporter la trace vers GPX'; + + @override + String get importTrailFromGpx => 'Importer la trace depuis GPX'; + + @override + String get trailExportedSuccessfully => 'Trace exportée avec succès!'; + + @override + String get failedToExportTrail => 'Échec de l\'exportation de la trace'; + + @override + String failedToImportTrail(String error) { + return 'Échec de l\'importation de la trace: $error'; + } + + @override + String get importTrail => 'Importer la trace'; + + @override + String importTrailQuestion(int pointCount) { + return 'Importer la trace avec $pointCount points?\n\nVous pouvez remplacer votre trace actuelle ou l\'afficher à côté.'; + } + + @override + String get viewAlongside => 'Afficher à côté'; + + @override + String get replaceCurrent => 'Remplacer l\'actuel'; + + @override + String trailImported(int pointCount) { + return 'Trace importée! ($pointCount points)'; + } + + @override + String trailReplaced(int pointCount) { + return 'Trace remplacée! ($pointCount points)'; + } + + @override + String get contactTrails => 'Traces des contacts'; + + @override + String get showAllContactTrails => 'Afficher toutes les traces des contacts'; + + @override + String get noContactsWithLocationHistory => + 'Aucun contact avec historique de localisation'; + + @override + String showingTrailsForContacts(int count) { + return 'Affichage des traces pour $count contacts'; + } + + @override + String get individualContactTrails => 'Traces individuelles des contacts'; + @override String get deviceInformation => 'Informations sur l\'appareil'; diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index 0713ea2..cad6995 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -1390,6 +1390,65 @@ class AppLocalizationsHr extends AppLocalizations { @override String get trailControls => 'Upravljanje putanjom'; + @override + String get exportTrailToGpx => 'Izvezi putanju u GPX'; + + @override + String get importTrailFromGpx => 'Uvezi putanju iz GPX'; + + @override + String get trailExportedSuccessfully => 'Putanja uspješno izvezena!'; + + @override + String get failedToExportTrail => 'Izvoz putanje nije uspio'; + + @override + String failedToImportTrail(String error) { + return 'Uvoz putanje nije uspio: $error'; + } + + @override + String get importTrail => 'Uvezi putanju'; + + @override + String importTrailQuestion(int pointCount) { + return 'Uvezi putanju s $pointCount točaka?\n\nMožete zamijeniti trenutnu putanju ili je prikazati zajedno.'; + } + + @override + String get viewAlongside => 'Prikaži zajedno'; + + @override + String get replaceCurrent => 'Zamijeni trenutnu'; + + @override + String trailImported(int pointCount) { + return 'Putanja uvezena! ($pointCount točaka)'; + } + + @override + String trailReplaced(int pointCount) { + return 'Putanja zamijenjena! ($pointCount točaka)'; + } + + @override + String get contactTrails => 'Putanje kontakata'; + + @override + String get showAllContactTrails => 'Prikaži sve putanje kontakata'; + + @override + String get noContactsWithLocationHistory => + 'Nema kontakata s poviješću lokacije'; + + @override + String showingTrailsForContacts(int count) { + return 'Prikazujem putanje za $count kontakata'; + } + + @override + String get individualContactTrails => 'Pojedinačne putanje kontakata'; + @override String get deviceInformation => 'Informacije o uređaju'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index fa90826..9fadc29 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1401,6 +1401,65 @@ class AppLocalizationsIt extends AppLocalizations { @override String get trailControls => 'Controlli Traccia'; + @override + String get exportTrailToGpx => 'Esporta traccia in GPX'; + + @override + String get importTrailFromGpx => 'Importa traccia da GPX'; + + @override + String get trailExportedSuccessfully => 'Traccia esportata con successo!'; + + @override + String get failedToExportTrail => 'Esportazione traccia fallita'; + + @override + String failedToImportTrail(String error) { + return 'Importazione traccia fallita: $error'; + } + + @override + String get importTrail => 'Importa traccia'; + + @override + String importTrailQuestion(int pointCount) { + return 'Importare traccia con $pointCount punti?\n\nPuoi sostituire la tua traccia attuale o visualizzarla affiancata.'; + } + + @override + String get viewAlongside => 'Visualizza affiancata'; + + @override + String get replaceCurrent => 'Sostituisci attuale'; + + @override + String trailImported(int pointCount) { + return 'Traccia importata! ($pointCount punti)'; + } + + @override + String trailReplaced(int pointCount) { + return 'Traccia sostituita! ($pointCount punti)'; + } + + @override + String get contactTrails => 'Tracce contatti'; + + @override + String get showAllContactTrails => 'Mostra tutte le tracce dei contatti'; + + @override + String get noContactsWithLocationHistory => + 'Nessun contatto con cronologia posizione'; + + @override + String showingTrailsForContacts(int count) { + return 'Visualizzazione tracce per $count contatti'; + } + + @override + String get individualContactTrails => 'Tracce individuali dei contatti'; + @override String get deviceInformation => 'Informazioni Dispositivo'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index d142395..36e412b 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1393,6 +1393,64 @@ class AppLocalizationsSl extends AppLocalizations { @override String get trailControls => 'Nadzor poti'; + @override + String get exportTrailToGpx => 'Izvozi pot v GPX'; + + @override + String get importTrailFromGpx => 'Uvozi pot iz GPX'; + + @override + String get trailExportedSuccessfully => 'Pot uspešno izvožena!'; + + @override + String get failedToExportTrail => 'Izvoz poti ni uspel'; + + @override + String failedToImportTrail(String error) { + return 'Uvoz poti ni uspel: $error'; + } + + @override + String get importTrail => 'Uvozi pot'; + + @override + String importTrailQuestion(int pointCount) { + return 'Uvozi pot s $pointCount točkami?\n\nLahko zamenjate trenutno pot ali jo prikažete skupaj.'; + } + + @override + String get viewAlongside => 'Prikaži skupaj'; + + @override + String get replaceCurrent => 'Zamenjaj trenutno'; + + @override + String trailImported(int pointCount) { + return 'Pot uvožena! ($pointCount točk)'; + } + + @override + String trailReplaced(int pointCount) { + return 'Pot zamenjana! ($pointCount točk)'; + } + + @override + String get contactTrails => 'Poti stikov'; + + @override + String get showAllContactTrails => 'Prikaži vse poti stikov'; + + @override + String get noContactsWithLocationHistory => 'Ni stikov z zgodovino lokacije'; + + @override + String showingTrailsForContacts(int count) { + return 'Prikazujem poti za $count stikov'; + } + + @override + String get individualContactTrails => 'Posamezne poti stikov'; + @override String get deviceInformation => 'Informacije o napravi'; diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 75516cd..54a5647 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -787,6 +787,38 @@ "trailControls": "Nadzor poti", + "exportTrailToGpx": "Izvozi pot v GPX", + + "importTrailFromGpx": "Uvozi pot iz GPX", + + "trailExportedSuccessfully": "Pot uspešno izvožena!", + + "failedToExportTrail": "Izvoz poti ni uspel", + + "failedToImportTrail": "Uvoz poti ni uspel: {error}", + + "importTrail": "Uvozi pot", + + "importTrailQuestion": "Uvozi pot s {pointCount} točkami?\n\nLahko zamenjate trenutno pot ali jo prikažete skupaj.", + + "viewAlongside": "Prikaži skupaj", + + "replaceCurrent": "Zamenjaj trenutno", + + "trailImported": "Pot uvožena! ({pointCount} točk)", + + "trailReplaced": "Pot zamenjana! ({pointCount} točk)", + + "contactTrails": "Poti stikov", + + "showAllContactTrails": "Prikaži vse poti stikov", + + "noContactsWithLocationHistory": "Ni stikov z zgodovino lokacije", + + "showingTrailsForContacts": "Prikazujem poti za {count} stikov", + + "individualContactTrails": "Posamezne poti stikov", + "deviceInformation": "Informacije o napravi", "bleName": "BLE ime", diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index ab4dd09..2430cd0 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -21,6 +21,12 @@ class MapProvider with ChangeNotifier { bool _showCadastralOverlay = false; bool _showForestRoadsOverlay = false; + // Contact trail toggles + bool _showAllContactTrails = false; + + // Imported trail (from GPX) + LocationTrail? _importedTrail; + LatLng? get targetLocation => _targetLocation; double? get targetZoom => _targetZoom; bool get shouldAnimate => _shouldAnimate; @@ -36,6 +42,12 @@ class MapProvider with ChangeNotifier { bool get showCadastralOverlay => _showCadastralOverlay; bool get showForestRoadsOverlay => _showForestRoadsOverlay; + // Contact trail getters + bool get showAllContactTrails => _showAllContactTrails; + + // Imported trail getters + LocationTrail? get importedTrail => _importedTrail; + void navigateToLocation({ required LatLng location, double zoom = 15.0, @@ -245,4 +257,49 @@ class MapProvider with ChangeNotifier { await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay); await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay); } + + /// Toggle all contact trails on/off + Future toggleAllContactTrails() async { + _showAllContactTrails = !_showAllContactTrails; + notifyListeners(); + await _saveTrailSettings(); + } + + /// Load trail settings from SharedPreferences + Future loadTrailSettings() async { + final prefs = await SharedPreferences.getInstance(); + _showAllContactTrails = prefs.getBool('map_show_all_contact_trails') ?? false; + notifyListeners(); + } + + /// Save trail settings to SharedPreferences + Future _saveTrailSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails); + } + + /// Set imported trail (from GPX import) + void setImportedTrail(LocationTrail trail) { + _importedTrail = trail; + notifyListeners(); + } + + /// Clear imported trail + void clearImportedTrail() { + _importedTrail = null; + notifyListeners(); + } + + /// Replace current trail with imported trail + void replaceCurrentTrailWithImport(LocationTrail importedTrail) { + // End current trail if active + if (_currentTrail != null && _currentTrail!.isActive) { + endTrail(); + } + + // Set imported trail as current trail + _currentTrail = importedTrail; + _isTrailVisible = true; + notifyListeners(); + } } diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 19838c0..ccfb9ea 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -28,6 +28,7 @@ import '../services/background_location_service.dart'; import '../services/location_tracking_service.dart'; import '../services/map_marker_service.dart'; import '../services/mbtiles_service.dart'; +import '../services/trail_color_service.dart'; import '../widgets/map_debug_info.dart'; import '../widgets/map/map_legend.dart'; import '../widgets/map/compass_widget.dart'; @@ -1441,22 +1442,55 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); }, ), - // Advertisement path polylines (rendered before markers) + // Imported trail layer (rendered at bottom for reference) Consumer( builder: (context, mapProvider, _) { + if (mapProvider.importedTrail == null || + mapProvider.importedTrail!.points.length < 2) { + return const SizedBox.shrink(); + } + return PolylineLayer( - polylines: contactsWithLocation - .where((contact) => mapProvider.isContactPathVisible(contact.publicKeyHex)) + polylines: [ + Polyline( + points: mapProvider.importedTrail!.latLngPoints, + color: Colors.green.withValues(alpha: 0.7), + strokeWidth: 3.0, + borderColor: Colors.white.withValues(alpha: 0.4), + borderStrokeWidth: 1.0, + // DOTTED pattern to distinguish from other trails + pattern: StrokePattern.dotted(spacingFactor: 2), + ), + ], + ); + }, + ), + // Contact trail polylines (rendered before user trail and markers) + Consumer( + builder: (context, mapProvider, _) { + // Determine which contacts to show trails for + final contactsToShow = mapProvider.showAllContactTrails + ? contactsWithLocation // Show all when master toggle is ON + : contactsWithLocation.where((contact) => + mapProvider.isContactPathVisible(contact.publicKeyHex)); // Individual toggles + + return PolylineLayer( + polylines: contactsToShow .where((contact) => contact.advertHistory.length >= 2) .map((contact) { + // Use TrailColorService for consistent, emoji-based colors + final color = TrailColorService.getTrailColor(contact); + return Polyline( points: contact.advertHistory .map((advert) => advert.location) .toList(), - color: Colors.blue.withValues(alpha: 0.7), - strokeWidth: 3.0, - borderColor: Colors.white.withValues(alpha: 0.5), + color: color, + strokeWidth: 2.5, // Thinner than user trail + borderColor: Colors.white.withValues(alpha: 0.3), borderStrokeWidth: 1.0, + // DASHED pattern to distinguish from solid user trail + pattern: StrokePattern.dashed(segments: [8, 4]), ); }) .toList(), diff --git a/lib/services/gpx_service.dart b/lib/services/gpx_service.dart new file mode 100644 index 0000000..c64c877 --- /dev/null +++ b/lib/services/gpx_service.dart @@ -0,0 +1,339 @@ +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 Share.shareXFiles( + [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')}'; + } +} diff --git a/lib/services/trail_color_service.dart b/lib/services/trail_color_service.dart new file mode 100644 index 0000000..e07ee70 --- /dev/null +++ b/lib/services/trail_color_service.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import '../models/contact.dart'; + +/// Service for assigning consistent colors to contact trails +/// Uses emoji-based semantic mapping with deterministic hash fallback +class TrailColorService { + // 64-color palette optimized for visibility on maps + // Organized by hue families for better distribution + // Avoids pure blue (#2196F3) which is reserved for user trail + static final List _colorPalette = [ + // Reds (8) + const Color(0xFFE53935), + const Color(0xFFD32F2F), + const Color(0xFFC62828), + const Color(0xFFB71C1C), + const Color(0xFFFF5252), + const Color(0xFFFF1744), + const Color(0xFFD50000), + const Color(0xFFC51162), + + // Pinks (4) + const Color(0xFFEC407A), + const Color(0xFFE91E63), + const Color(0xFFC2185B), + const Color(0xFFAD1457), + + // Purples (8) + const Color(0xFF9C27B0), + const Color(0xFF8E24AA), + const Color(0xFF7B1FA2), + const Color(0xFF6A1B9A), + const Color(0xFFAB47BC), + const Color(0xFF9C27B0), + const Color(0xFF8E24AA), + const Color(0xFF7B1FA2), + + // Deep Purples (4) + const Color(0xFF673AB7), + const Color(0xFF5E35B1), + const Color(0xFF512DA8), + const Color(0xFF4527A0), + + // Indigos (4) + const Color(0xFF3F51B5), + const Color(0xFF3949AB), + const Color(0xFF303F9F), + const Color(0xFF283593), + + // Blues (4) - Dark blues only, avoid user trail blue + const Color(0xFF1E88E5), + const Color(0xFF1976D2), + const Color(0xFF1565C0), + const Color(0xFF0D47A1), + + // Cyans (4) + const Color(0xFF00ACC1), + const Color(0xFF0097A7), + const Color(0xFF00838F), + const Color(0xFF006064), + + // Teals (4) + const Color(0xFF00897B), + const Color(0xFF00796B), + const Color(0xFF00695C), + const Color(0xFF004D40), + + // Greens (8) + const Color(0xFF43A047), + const Color(0xFF388E3C), + const Color(0xFF2E7D32), + const Color(0xFF1B5E20), + const Color(0xFF66BB6A), + const Color(0xFF4CAF50), + const Color(0xFF388E3C), + const Color(0xFF2E7D32), + + // Limes (4) + const Color(0xFF9E9D24), + const Color(0xFF827717), + const Color(0xFFC0CA33), + const Color(0xFFAFB42B), + + // Ambers (4) + const Color(0xFFFFA726), + const Color(0xFFFF9800), + const Color(0xFFFB8C00), + const Color(0xFFF57C00), + + // Oranges (4) + const Color(0xFFFF7043), + const Color(0xFFFF5722), + const Color(0xFFF4511E), + const Color(0xFFE64A19), + + // Browns (4) + const Color(0xFF6D4C41), + const Color(0xFF5D4037), + const Color(0xFF4E342E), + const Color(0xFF3E2723), + ]; + + // Emoji to color mapping for SAR roles + // Uses semantic colors that match emergency service conventions + static final Map _emojiColorMap = { + // Emergency Services - Firefighters + '🚒': Color(0xFFD32F2F), // Fire engine → Red + '🧑‍🚒': Color(0xFFD32F2F), // Firefighter → Red + '👨‍🚒': Color(0xFFD32F2F), // Firefighter → Red + '👩‍🚒': Color(0xFFD32F2F), // Firefighter → Red + '🔥': Color(0xFFFF5722), // Fire → Orange-Red + + // Emergency Services - Medical + '🚑': Color(0xFF43A047), // Ambulance → Green (medical cross) + '👨‍⚕️': Color(0xFF43A047), // Health worker → Green + '👩‍⚕️': Color(0xFF43A047), // Health worker → Green + '🧑‍⚕️': Color(0xFF43A047), // Health worker → Green + '⚕️': Color(0xFF43A047), // Medical symbol → Green + + // Emergency Services - Police + '👮': Color(0xFF1976D2), // Police → Blue + '👮‍♂️': Color(0xFF1976D2), // Police → Blue + '👮‍♀️': Color(0xFF1976D2), // Police → Blue + '🚔': Color(0xFF1976D2), // Police car → Blue + + // Emergency Services - Aviation + '🧑‍✈️': Color(0xFF1565C0), // Pilot → Dark Blue + '👨‍✈️': Color(0xFF1565C0), // Pilot → Dark Blue + '👩‍✈️': Color(0xFF1565C0), // Pilot → Dark Blue + '🚁': Color(0xFF8E24AA), // Helicopter → Purple + + // SAR Roles - Mountain/Alpine + '🏔️': Color(0xFF6D4C41), // Mountain → Brown + '⛰️': Color(0xFF6D4C41), // Mountain → Brown + '🧗': Color(0xFF6D4C41), // Climber → Brown + '🧗‍♂️': Color(0xFF6D4C41), // Climber → Brown + '🧗‍♀️': Color(0xFF6D4C41), // Climber → Brown + '🥾': Color(0xFF5D4037), // Hiking boot → Dark Brown + + // SAR Roles - K9 Unit + '🐕': Color(0xFFFFA726), // Dog → Orange + '🐶': Color(0xFFFFA726), // Dog → Orange + '🦮': Color(0xFFFFA726), // Service dog → Orange + + // SAR Roles - Water Rescue + '🚤': Color(0xFF00ACC1), // Speedboat → Cyan + '⛵': Color(0xFF00ACC1), // Sailboat → Cyan + '🏊': Color(0xFF00897B), // Swimmer → Teal + '🏊‍♂️': Color(0xFF00897B), // Swimmer → Teal + '🏊‍♀️': Color(0xFF00897B), // Swimmer → Teal + + // Team Roles - Leadership + '🎯': Color(0xFFE64A19), // Target → Deep Orange (team leader) + '⭐': Color(0xFFFDD835), // Star → Yellow (coordinator) + '👑': Color(0xFFFDD835), // Crown → Yellow (leader) + + // Team Roles - Communication + '📡': Color(0xFF00897B), // Satellite → Teal (radio/comms) + '📻': Color(0xFF00897B), // Radio → Teal + '📞': Color(0xFF00897B), // Phone → Teal + + // Team Roles - Navigation + '🗺️': Color(0xFF00ACC1), // Map → Cyan (navigator) + '🧭': Color(0xFF00ACC1), // Compass → Cyan + '📍': Color(0xFFE53935), // Pin → Red (location marker) + + // Team Roles - Documentation + '📷': Color(0xFFAB47BC), // Camera → Light Purple + '📹': Color(0xFFAB47BC), // Video camera → Light Purple + '📝': Color(0xFF9E9D24), // Note → Lime (scribe) + + // Equipment + '🔦': Color(0xFFFB8C00), // Flashlight → Amber + '⚡': Color(0xFFFDD835), // Lightning → Yellow (power/energy) + '🔋': Color(0xFF43A047), // Battery → Green + '🎒': Color(0xFF5D4037), // Backpack → Brown + + // Generic Person Icons + '👤': Color(0xFF9E9E9E), // Silhouette → Gray + '🧑': Color(0xFF9E9E9E), // Person → Gray + '👨': Color(0xFF9E9E9E), // Man → Gray + '👩': Color(0xFF9E9E9E), // Woman → Gray + '👥': Color(0xFF757575), // People → Dark Gray + }; + + /// Get trail color for a contact + /// Priority: Emoji mapping > Name hash > Default + static Color getTrailColor(Contact contact) { + // 1. Try emoji-based color mapping + if (contact.roleEmoji != null) { + final emojiColor = _emojiColorMap[contact.roleEmoji]; + if (emojiColor != null) { + // Return with slight transparency for better map visibility + return emojiColor.withValues(alpha: 0.75); + } + } + + // 2. Deterministic color based on display name + // Use display name (without emoji) for consistent hashing + final name = contact.displayName.isNotEmpty + ? contact.displayName + : contact.publicKeyHex; + + final hash = _hashString(name); + final colorIndex = hash % _colorPalette.length; // 0-63 + + return _colorPalette[colorIndex].withValues(alpha: 0.75); + } + + /// Simple string hash function (DJB2 algorithm) + /// Same algorithm used for echo detection in the app + static int _hashString(String str) { + int hash = 5381; + for (int i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) + str.codeUnitAt(i); + hash = hash & 0xFFFFFFFF; // Keep 32-bit + } + return hash.abs(); + } + + /// Get all unique colors currently in use by contacts with trails + static List getActiveColors(List contacts) { + final colors = {}; + for (final contact in contacts) { + if (contact.advertHistory.length >= 2) { + colors.add(getTrailColor(contact)); + } + } + return colors.toList(); + } + + /// Check if a color is from emoji mapping (semantic) vs hash-based + static bool isSemanticColor(Contact contact) { + if (contact.roleEmoji == null) return false; + return _emojiColorMap.containsKey(contact.roleEmoji); + } +} diff --git a/lib/widgets/map/detailed_compass_dialog.dart b/lib/widgets/map/detailed_compass_dialog.dart index 5d519ed..a58bfcf 100644 --- a/lib/widgets/map/detailed_compass_dialog.dart +++ b/lib/widgets/map/detailed_compass_dialog.dart @@ -621,26 +621,7 @@ class _DetailedCompassDialogState extends State { ], ), ), - // Show path toggle button for contacts with history - if (_selectedContact != null && _selectedContact!.advertHistory.length >= 2) - Consumer( - builder: (context, mapProvider, _) { - final isPathVisible = mapProvider.isContactPathVisible(_selectedContact!.publicKeyHex); - return IconButton( - icon: Icon( - isPathVisible ? Icons.route : Icons.route_outlined, - size: 20, - color: isPathVisible ? Theme.of(context).colorScheme.primary : null, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: isPathVisible ? 'Hide path' : 'Show path (${_selectedContact!.advertHistory.length} points)', - onPressed: () { - mapProvider.toggleContactPath(_selectedContact!.publicKeyHex); - }, - ); - }, - ), + // Close button to deselect contact IconButton( icon: const Icon(Icons.close, size: 20), padding: EdgeInsets.zero, diff --git a/lib/widgets/map/trail_controls.dart b/lib/widgets/map/trail_controls.dart index b740f54..aeae12a 100644 --- a/lib/widgets/map/trail_controls.dart +++ b/lib/widgets/map/trail_controls.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/map_provider.dart'; +import '../../providers/contacts_provider.dart'; +import '../../providers/app_provider.dart'; +import '../../services/gpx_service.dart'; +import '../../services/trail_color_service.dart'; import '../../l10n/app_localizations.dart'; /// Trail management controls widget @@ -9,18 +13,27 @@ class TrailControls extends StatelessWidget { void _showTrailMenu(BuildContext context) { final mapProvider = Provider.of(context, listen: false); + final contactsProvider = Provider.of(context, listen: false); + final appProvider = Provider.of(context, listen: false); final l10n = AppLocalizations.of(context)!; + final isSimpleMode = appProvider.isSimpleMode; + + // Get contacts with trails (advertHistory >= 2 points) + final contactsWithTrails = contactsProvider.contactsWithLocation + .where((c) => c.advertHistory.length >= 2) + .toList(); showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => StatefulBuilder( - builder: (context, setModalState) => Container( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + builder: (context, setModalState) => SingleChildScrollView( + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ Row( children: [ const Icon(Icons.timeline, size: 24), @@ -89,6 +102,60 @@ class TrailControls extends StatelessWidget { const SizedBox(height: 16), + // GPX Export/Import buttons (hidden in simple mode) + if (!isSimpleMode) ...[ + if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty) + ElevatedButton.icon( + onPressed: () async { + final success = await GpxService.exportTrailToFile(mapProvider.currentTrail!); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success + ? l10n.trailExportedSuccessfully + : l10n.failedToExportTrail), + backgroundColor: success ? Colors.green : Colors.red, + ), + ); + } + }, + icon: const Icon(Icons.upload), + label: Text(l10n.exportTrailToGpx), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + ), + ), + + const SizedBox(height: 8), + + ElevatedButton.icon( + onPressed: () async { + try { + final trail = await GpxService.importTrailFromFile(); + if (trail != null && context.mounted) { + _showImportDialog(context, mapProvider, trail, l10n); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.failedToImportTrail(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + } + }, + icon: const Icon(Icons.download), + label: Text(l10n.importTrailFromGpx), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + ), + ), + + const SizedBox(height: 16), + ], + // Clear trail button if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty) ElevatedButton.icon( @@ -134,6 +201,81 @@ class TrailControls extends StatelessWidget { ), ), + const SizedBox(height: 8), + const Divider(), + const SizedBox(height: 8), + + // Contact Trails Section + Row( + children: [ + const Icon(Icons.people, size: 20), + const SizedBox(width: 8), + Text( + l10n.contactTrails, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Show All Contact Trails toggle + SwitchListTile( + secondary: const Icon(Icons.route), + title: Text(l10n.showAllContactTrails), + subtitle: Text(contactsWithTrails.isEmpty + ? l10n.noContactsWithLocationHistory + : l10n.showingTrailsForContacts(contactsWithTrails.length)), + value: mapProvider.showAllContactTrails, + onChanged: contactsWithTrails.isNotEmpty + ? (value) { + mapProvider.toggleAllContactTrails(); + setModalState(() {}); // Update modal UI + } + : null, // Disable if no contacts with trails + ), + + // Individual contact trails (when "show all" is OFF) + if (!mapProvider.showAllContactTrails && contactsWithTrails.isNotEmpty) + ExpansionTile( + title: Text(l10n.individualContactTrails), + initiallyExpanded: false, + children: contactsWithTrails.map((contact) { + final trailColor = TrailColorService.getTrailColor(contact); + final isVisible = mapProvider.isContactPathVisible(contact.publicKeyHex); + + return SwitchListTile( + // Color indicator with emoji + secondary: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (contact.roleEmoji != null) + Text(contact.roleEmoji!, style: const TextStyle(fontSize: 18)), + const SizedBox(width: 4), + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: trailColor, + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(3), + ), + ), + ], + ), + title: Text(contact.displayName), + subtitle: Text('${contact.advertHistory.length} points'), + value: isVisible, + onChanged: (value) { + mapProvider.toggleContactPath(contact.publicKeyHex); + setModalState(() {}); // Update modal UI + }, + ); + }).toList(), + ), + const SizedBox(height: 8), // Close button @@ -142,6 +284,7 @@ class TrailControls extends StatelessWidget { child: Text(l10n.close), ), ], + ), ), ), ), @@ -173,6 +316,50 @@ class TrailControls extends StatelessWidget { ); } + void _showImportDialog(BuildContext context, MapProvider mapProvider, trail, AppLocalizations l10n) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.importTrail), + content: Text(l10n.importTrailQuestion(trail.points.length)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () { + mapProvider.setImportedTrail(trail); + Navigator.pop(context); // Close dialog + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.trailImported(trail.points.length)), + backgroundColor: Colors.green, + ), + ); + }, + child: Text(l10n.viewAlongside), + ), + TextButton( + onPressed: () { + mapProvider.replaceCurrentTrailWithImport(trail); + Navigator.pop(context); // Close dialog + Navigator.pop(context); // Close bottom sheet + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.trailReplaced(trail.points.length)), + backgroundColor: Colors.green, + ), + ); + }, + style: TextButton.styleFrom(foregroundColor: Colors.blue), + child: Text(l10n.replaceCurrent), + ), + ], + ), + ); + } + Widget _buildStatRow({ required IconData icon, required String label, diff --git a/pubspec.lock b/pubspec.lock index f434e39..d11342d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1180,7 +1180,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index 56de59d..418cab9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -99,6 +99,9 @@ dependencies: # Cryptographic functions (for channel secret hashing) crypto: ^3.0.3 + # XML parsing for GPX import/export + xml: ^6.5.0 + dev_dependencies: flutter_test: sdk: flutter