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.
This commit is contained in:
Janez T
2025-10-28 11:04:09 +01:00
parent a1207ad844
commit e7fe700fee
23 changed files with 1967 additions and 33 deletions

View File

@@ -2315,6 +2315,111 @@
"description": "Tooltip für Verlaufssteuerung" "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": "Geräteinformationen",
"@deviceInformation": { "@deviceInformation": {
"description": "Überschrift des Geräteinformationen-Bereichs" "description": "Überschrift des Geräteinformationen-Bereichs"

View File

@@ -2375,6 +2375,111 @@
"description": "Trail controls tooltip" "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": "Device Information",
"@deviceInformation": { "@deviceInformation": {
"description": "Device information section header" "description": "Device information section header"

View File

@@ -2310,6 +2310,111 @@
"description": "Tooltip de controles del rastro" "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": "Información del dispositivo",
"@deviceInformation": { "@deviceInformation": {
"description": "Encabezado de la sección de información del dispositivo" "description": "Encabezado de la sección de información del dispositivo"

View File

@@ -2315,6 +2315,111 @@
"description": "Info-bulle des contrôles de la trace" "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": "Informations sur l'appareil",
"@deviceInformation": { "@deviceInformation": {
"description": "En-tête de section des informations sur l'appareil" "description": "En-tête de section des informations sur l'appareil"

View File

@@ -787,6 +787,38 @@
"trailControls": "Upravljanje putanjom", "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", "deviceInformation": "Informacije o uređaju",
"bleName": "BLE naziv", "bleName": "BLE naziv",

View File

@@ -2315,6 +2315,111 @@
"description": "Tooltip controlli traccia" "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": "Informazioni Dispositivo",
"@deviceInformation": { "@deviceInformation": {
"description": "Intestazione sezione informazioni dispositivo" "description": "Intestazione sezione informazioni dispositivo"

View File

@@ -2517,6 +2517,102 @@ abstract class AppLocalizations {
/// **'Trail Controls'** /// **'Trail Controls'**
String get trailControls; 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 /// Device information section header
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -1402,6 +1402,65 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get trailControls => 'Verlaufssteuerung'; 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 @override
String get deviceInformation => 'Geräteinformationen'; String get deviceInformation => 'Geräteinformationen';

View File

@@ -1390,6 +1390,65 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get trailControls => 'Trail Controls'; 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 @override
String get deviceInformation => 'Device Information'; String get deviceInformation => 'Device Information';

View File

@@ -1401,6 +1401,65 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get trailControls => 'Controles del rastro'; 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 @override
String get deviceInformation => 'Información del dispositivo'; String get deviceInformation => 'Información del dispositivo';

View File

@@ -1407,6 +1407,65 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get trailControls => 'Contrôles de la trace'; 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 @override
String get deviceInformation => 'Informations sur l\'appareil'; String get deviceInformation => 'Informations sur l\'appareil';

View File

@@ -1390,6 +1390,65 @@ class AppLocalizationsHr extends AppLocalizations {
@override @override
String get trailControls => 'Upravljanje putanjom'; 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 @override
String get deviceInformation => 'Informacije o uređaju'; String get deviceInformation => 'Informacije o uređaju';

View File

@@ -1401,6 +1401,65 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get trailControls => 'Controlli Traccia'; 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 @override
String get deviceInformation => 'Informazioni Dispositivo'; String get deviceInformation => 'Informazioni Dispositivo';

View File

@@ -1393,6 +1393,64 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get trailControls => 'Nadzor poti'; 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 @override
String get deviceInformation => 'Informacije o napravi'; String get deviceInformation => 'Informacije o napravi';

View File

@@ -787,6 +787,38 @@
"trailControls": "Nadzor poti", "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", "deviceInformation": "Informacije o napravi",
"bleName": "BLE ime", "bleName": "BLE ime",

View File

@@ -21,6 +21,12 @@ class MapProvider with ChangeNotifier {
bool _showCadastralOverlay = false; bool _showCadastralOverlay = false;
bool _showForestRoadsOverlay = false; bool _showForestRoadsOverlay = false;
// Contact trail toggles
bool _showAllContactTrails = false;
// Imported trail (from GPX)
LocationTrail? _importedTrail;
LatLng? get targetLocation => _targetLocation; LatLng? get targetLocation => _targetLocation;
double? get targetZoom => _targetZoom; double? get targetZoom => _targetZoom;
bool get shouldAnimate => _shouldAnimate; bool get shouldAnimate => _shouldAnimate;
@@ -36,6 +42,12 @@ class MapProvider with ChangeNotifier {
bool get showCadastralOverlay => _showCadastralOverlay; bool get showCadastralOverlay => _showCadastralOverlay;
bool get showForestRoadsOverlay => _showForestRoadsOverlay; bool get showForestRoadsOverlay => _showForestRoadsOverlay;
// Contact trail getters
bool get showAllContactTrails => _showAllContactTrails;
// Imported trail getters
LocationTrail? get importedTrail => _importedTrail;
void navigateToLocation({ void navigateToLocation({
required LatLng location, required LatLng location,
double zoom = 15.0, 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_cadastral_overlay', _showCadastralOverlay);
await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay); await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay);
} }
/// Toggle all contact trails on/off
Future<void> toggleAllContactTrails() async {
_showAllContactTrails = !_showAllContactTrails;
notifyListeners();
await _saveTrailSettings();
}
/// Load trail settings from SharedPreferences
Future<void> loadTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
_showAllContactTrails = prefs.getBool('map_show_all_contact_trails') ?? false;
notifyListeners();
}
/// Save trail settings to SharedPreferences
Future<void> _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();
}
} }

View File

@@ -28,6 +28,7 @@ import '../services/background_location_service.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/map_marker_service.dart'; import '../services/map_marker_service.dart';
import '../services/mbtiles_service.dart'; import '../services/mbtiles_service.dart';
import '../services/trail_color_service.dart';
import '../widgets/map_debug_info.dart'; import '../widgets/map_debug_info.dart';
import '../widgets/map/map_legend.dart'; import '../widgets/map/map_legend.dart';
import '../widgets/map/compass_widget.dart'; import '../widgets/map/compass_widget.dart';
@@ -1441,22 +1442,55 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
); );
}, },
), ),
// Advertisement path polylines (rendered before markers) // Imported trail layer (rendered at bottom for reference)
Consumer<MapProvider>( Consumer<MapProvider>(
builder: (context, mapProvider, _) { builder: (context, mapProvider, _) {
if (mapProvider.importedTrail == null ||
mapProvider.importedTrail!.points.length < 2) {
return const SizedBox.shrink();
}
return PolylineLayer( return PolylineLayer(
polylines: contactsWithLocation polylines: [
.where((contact) => mapProvider.isContactPathVisible(contact.publicKeyHex)) 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<MapProvider>(
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) .where((contact) => contact.advertHistory.length >= 2)
.map((contact) { .map((contact) {
// Use TrailColorService for consistent, emoji-based colors
final color = TrailColorService.getTrailColor(contact);
return Polyline( return Polyline(
points: contact.advertHistory points: contact.advertHistory
.map((advert) => advert.location) .map((advert) => advert.location)
.toList(), .toList(),
color: Colors.blue.withValues(alpha: 0.7), color: color,
strokeWidth: 3.0, strokeWidth: 2.5, // Thinner than user trail
borderColor: Colors.white.withValues(alpha: 0.5), borderColor: Colors.white.withValues(alpha: 0.3),
borderStrokeWidth: 1.0, borderStrokeWidth: 1.0,
// DASHED pattern to distinguish from solid user trail
pattern: StrokePattern.dashed(segments: [8, 4]),
); );
}) })
.toList(), .toList(),

View File

@@ -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 <gpx> 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 = <TrailPoint>[];
final tracks = gpxElement.findElements('trk');
if (tracks.isEmpty) {
throw const FormatException(
'Invalid GPX file: No <trk> 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<bool> 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<LocationTrail?> 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')}';
}
}

View File

@@ -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<Color> _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<String, Color> _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<Color> getActiveColors(List<Contact> contacts) {
final colors = <Color>{};
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);
}
}

View File

@@ -621,26 +621,7 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
], ],
), ),
), ),
// Show path toggle button for contacts with history // Close button to deselect contact
if (_selectedContact != null && _selectedContact!.advertHistory.length >= 2)
Consumer<MapProvider>(
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);
},
);
},
),
IconButton( IconButton(
icon: const Icon(Icons.close, size: 20), icon: const Icon(Icons.close, size: 20),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,

View File

@@ -1,6 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../providers/map_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'; import '../../l10n/app_localizations.dart';
/// Trail management controls widget /// Trail management controls widget
@@ -9,13 +13,22 @@ class TrailControls extends StatelessWidget {
void _showTrailMenu(BuildContext context) { void _showTrailMenu(BuildContext context) {
final mapProvider = Provider.of<MapProvider>(context, listen: false); final mapProvider = Provider.of<MapProvider>(context, listen: false);
final contactsProvider = Provider.of<ContactsProvider>(context, listen: false);
final appProvider = Provider.of<AppProvider>(context, listen: false);
final l10n = AppLocalizations.of(context)!; 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( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (context) => StatefulBuilder( builder: (context) => StatefulBuilder(
builder: (context, setModalState) => Container( builder: (context, setModalState) => SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -89,6 +102,60 @@ class TrailControls extends StatelessWidget {
const SizedBox(height: 16), 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 // Clear trail button
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty) if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
ElevatedButton.icon( 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), const SizedBox(height: 8),
// Close button // Close button
@@ -145,6 +287,7 @@ class TrailControls extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
@@ -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({ Widget _buildStatRow({
required IconData icon, required IconData icon,
required String label, required String label,

View File

@@ -1180,7 +1180,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml: xml:
dependency: transitive dependency: "direct main"
description: description:
name: xml name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"

View File

@@ -99,6 +99,9 @@ dependencies:
# Cryptographic functions (for channel secret hashing) # Cryptographic functions (for channel secret hashing)
crypto: ^3.0.3 crypto: ^3.0.3
# XML parsing for GPX import/export
xml: ^6.5.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter