mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add localization support for German, Spanish, French, and Italian
- Updated localization files for French, Croatian, Italian, Slovenian to include translations for German, Spanish, French, and Italian. - Added new methods for handling drawing messages sent to the public channel in multiple languages. - Enhanced the Contact model to identify public channels using a dedicated method. - Implemented echo detection for public channel messages, including tracking and reporting of echoes. - Updated BLE response handling to support echo detection and tracking of sent messages. - Modified UI components to reflect new localization strings and echo statuses.
This commit is contained in:
@@ -232,6 +232,26 @@
|
||||
"description": "Kroatische Sprachoption"
|
||||
},
|
||||
|
||||
"german": "Deutsch",
|
||||
"@german": {
|
||||
"description": "Deutsche Sprachoption"
|
||||
},
|
||||
|
||||
"spanish": "Spanisch",
|
||||
"@spanish": {
|
||||
"description": "Spanische Sprachoption"
|
||||
},
|
||||
|
||||
"french": "Französisch",
|
||||
"@french": {
|
||||
"description": "Französische Sprachoption"
|
||||
},
|
||||
|
||||
"italian": "Italienisch",
|
||||
"@italian": {
|
||||
"description": "Italienische Sprachoption"
|
||||
},
|
||||
|
||||
"locationBroadcasting": "Standortübertragung",
|
||||
"@locationBroadcasting": {
|
||||
"description": "Überschrift des Standorteinstellungsbereichs"
|
||||
@@ -639,6 +659,24 @@
|
||||
"description": "Beschreibung für dauerhafte Raumspeicherung"
|
||||
},
|
||||
|
||||
"drawingsSentToPublicChannel": "{count} Kartenzeichnung{plural} an öffentlichen Kanal gesendet",
|
||||
"@drawingsSentToPublicChannel": {
|
||||
"description": "Systemnachricht, wenn Zeichnungen an den öffentlichen Kanal gesendet werden",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"plural": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"drawingsSharedToPublicChannel": "{success}/{total} Zeichnungen mit öffentlichem Kanal geteilt",
|
||||
"@drawingsSharedToPublicChannel": {
|
||||
"description": "Snackbar-Nachricht, die die Erfolgsanzahl für geteilte Zeichnungen im öffentlichen Kanal anzeigt",
|
||||
"placeholders": {
|
||||
"success": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
|
||||
"notConnectedToDevice": "Nicht mit Gerät verbunden",
|
||||
"@notConnectedToDevice": {
|
||||
"description": "Fehlermeldung, wenn das Gerät nicht für Direktnachrichten verbunden ist"
|
||||
@@ -1715,6 +1753,11 @@
|
||||
"description": "Zustellungsstatus: fehlgeschlagen"
|
||||
},
|
||||
|
||||
"broadcast": "Broadcast",
|
||||
"@broadcast": {
|
||||
"description": "Zustellstatus für Kanalnachrichten (noch keine Echos)"
|
||||
},
|
||||
|
||||
"sarMarkerFoundPerson": "Person gefunden",
|
||||
"@sarMarkerFoundPerson": {
|
||||
"description": "SAR-Markierungstyp: Person gefunden"
|
||||
|
||||
@@ -232,6 +232,26 @@
|
||||
"description": "Croatian language option"
|
||||
},
|
||||
|
||||
"german": "German",
|
||||
"@german": {
|
||||
"description": "German language option"
|
||||
},
|
||||
|
||||
"spanish": "Spanish",
|
||||
"@spanish": {
|
||||
"description": "Spanish language option"
|
||||
},
|
||||
|
||||
"french": "French",
|
||||
"@french": {
|
||||
"description": "French language option"
|
||||
},
|
||||
|
||||
"italian": "Italian",
|
||||
"@italian": {
|
||||
"description": "Italian language option"
|
||||
},
|
||||
|
||||
"locationBroadcasting": "Location Broadcasting",
|
||||
"@locationBroadcasting": {
|
||||
"description": "Location settings section header"
|
||||
@@ -639,6 +659,24 @@
|
||||
"description": "Description for room storage permanence"
|
||||
},
|
||||
|
||||
"drawingsSentToPublicChannel": "Sent {count} map drawing{plural} to Public Channel",
|
||||
"@drawingsSentToPublicChannel": {
|
||||
"description": "System message when drawings are sent to public channel",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"plural": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"drawingsSharedToPublicChannel": "Shared {success}/{total} drawings to Public Channel",
|
||||
"@drawingsSharedToPublicChannel": {
|
||||
"description": "Snackbar message showing success count for drawings shared to public channel",
|
||||
"placeholders": {
|
||||
"success": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
|
||||
"notConnectedToDevice": "Not connected to device",
|
||||
"@notConnectedToDevice": {
|
||||
"description": "Error message when device is not connected for direct messaging"
|
||||
@@ -1715,6 +1753,11 @@
|
||||
"description": "Delivery status: failed"
|
||||
},
|
||||
|
||||
"broadcast": "Broadcast",
|
||||
"@broadcast": {
|
||||
"description": "Delivery status for channel messages (no echoes yet)"
|
||||
},
|
||||
|
||||
"sarMarkerFoundPerson": "Found Person",
|
||||
"@sarMarkerFoundPerson": {
|
||||
"description": "SAR marker type: found person"
|
||||
|
||||
@@ -232,6 +232,26 @@
|
||||
"description": "Opción de idioma croata"
|
||||
},
|
||||
|
||||
"german": "Alemán",
|
||||
"@german": {
|
||||
"description": "Opción de idioma alemán"
|
||||
},
|
||||
|
||||
"spanish": "Español",
|
||||
"@spanish": {
|
||||
"description": "Opción de idioma español"
|
||||
},
|
||||
|
||||
"french": "Francés",
|
||||
"@french": {
|
||||
"description": "Opción de idioma francés"
|
||||
},
|
||||
|
||||
"italian": "Italiano",
|
||||
"@italian": {
|
||||
"description": "Opción de idioma italiano"
|
||||
},
|
||||
|
||||
"locationBroadcasting": "Difusión de ubicación",
|
||||
"@locationBroadcasting": {
|
||||
"description": "Encabezado de la sección de configuración de ubicación"
|
||||
@@ -639,6 +659,24 @@
|
||||
"description": "Descripción de la permanencia del almacenamiento en la sala"
|
||||
},
|
||||
|
||||
"drawingsSentToPublicChannel": "{count} dibujo{plural} de mapa enviado al Canal Público",
|
||||
"@drawingsSentToPublicChannel": {
|
||||
"description": "Mensaje del sistema cuando se envían dibujos al canal público",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"plural": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"drawingsSharedToPublicChannel": "{success}/{total} dibujos compartidos al Canal Público",
|
||||
"@drawingsSharedToPublicChannel": {
|
||||
"description": "Mensaje de snackbar mostrando el recuento de éxitos para dibujos compartidos al canal público",
|
||||
"placeholders": {
|
||||
"success": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
|
||||
"notConnectedToDevice": "No conectado al dispositivo",
|
||||
"@notConnectedToDevice": {
|
||||
"description": "Mensaje de error cuando el dispositivo no está conectado para mensajería directa"
|
||||
@@ -1715,6 +1753,11 @@
|
||||
"description": "Estado de entrega: fallido"
|
||||
},
|
||||
|
||||
"broadcast": "Difusión",
|
||||
"@broadcast": {
|
||||
"description": "Estado de entrega para mensajes de canal (sin ecos aún)"
|
||||
},
|
||||
|
||||
"sarMarkerFoundPerson": "Persona encontrada",
|
||||
"@sarMarkerFoundPerson": {
|
||||
"description": "Tipo de marcador SAR: persona encontrada"
|
||||
|
||||
@@ -232,6 +232,26 @@
|
||||
"description": "Option de langue croate"
|
||||
},
|
||||
|
||||
"german": "Allemand",
|
||||
"@german": {
|
||||
"description": "Option de langue allemande"
|
||||
},
|
||||
|
||||
"spanish": "Espagnol",
|
||||
"@spanish": {
|
||||
"description": "Option de langue espagnole"
|
||||
},
|
||||
|
||||
"french": "Français",
|
||||
"@french": {
|
||||
"description": "Option de langue française"
|
||||
},
|
||||
|
||||
"italian": "Italien",
|
||||
"@italian": {
|
||||
"description": "Option de langue italienne"
|
||||
},
|
||||
|
||||
"locationBroadcasting": "Diffusion de position",
|
||||
"@locationBroadcasting": {
|
||||
"description": "En-tête de section des paramètres de localisation"
|
||||
@@ -639,6 +659,24 @@
|
||||
"description": "Description de la permanence du stockage dans le salon"
|
||||
},
|
||||
|
||||
"drawingsSentToPublicChannel": "{count} dessin{plural} de carte envoyé au Canal Public",
|
||||
"@drawingsSentToPublicChannel": {
|
||||
"description": "Message système lors de l'envoi de dessins au canal public",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"plural": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"drawingsSharedToPublicChannel": "{success}/{total} dessins partagés sur le Canal Public",
|
||||
"@drawingsSharedToPublicChannel": {
|
||||
"description": "Message de snackbar montrant le nombre de succès pour les dessins partagés sur le canal public",
|
||||
"placeholders": {
|
||||
"success": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
|
||||
"notConnectedToDevice": "Non connecté à l'appareil",
|
||||
"@notConnectedToDevice": {
|
||||
"description": "Message d'erreur lorsque l'appareil n'est pas connecté pour la messagerie directe"
|
||||
@@ -1715,6 +1753,11 @@
|
||||
"description": "État de livraison : échec"
|
||||
},
|
||||
|
||||
"broadcast": "Diffusion",
|
||||
"@broadcast": {
|
||||
"description": "État de livraison pour les messages de canal (pas encore d'échos)"
|
||||
},
|
||||
|
||||
"sarMarkerFoundPerson": "Personne trouvée",
|
||||
"@sarMarkerFoundPerson": {
|
||||
"description": "Type de marqueur SAR : personne trouvée"
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
|
||||
"croatian": "Hrvatski",
|
||||
|
||||
"german": "Njemački",
|
||||
|
||||
"spanish": "Španjolski",
|
||||
|
||||
"french": "Francuski",
|
||||
|
||||
"italian": "Talijanski",
|
||||
|
||||
"locationBroadcasting": "Emitiranje lokacije",
|
||||
|
||||
"autoLocationTracking": "Automatsko praćenje lokacije",
|
||||
@@ -223,6 +231,10 @@
|
||||
|
||||
"storedPermanently": "Trajno pohranjeno u sobi",
|
||||
|
||||
"drawingsSentToPublicChannel": "Poslano {count} crtež{plural} na javni kanal",
|
||||
|
||||
"drawingsSharedToPublicChannel": "Podijeljeno {success}/{total} crteža na javni kanal",
|
||||
|
||||
"notConnectedToDevice": "Nije povezano s uređajem",
|
||||
|
||||
"directMessage": "Izravna poruka",
|
||||
@@ -285,7 +297,7 @@
|
||||
|
||||
"direct": "Izravno",
|
||||
|
||||
"flood": "Poplava",
|
||||
"flood": "Preplavljanje",
|
||||
|
||||
"admin": "Administrator",
|
||||
|
||||
@@ -297,13 +309,13 @@
|
||||
|
||||
"pingingDirect": "Pingiranje {name} (izravno putem puta)...",
|
||||
|
||||
"pingingFlood": "Pingiranje {name} (poplava - nema puta)...",
|
||||
"pingingFlood": "Pingiranje {name} (preplavljanje - nema puta)...",
|
||||
|
||||
"directPingTimeout": "Istek izravnog pinga - ponovni pokušaj {name} s poplavom...",
|
||||
"directPingTimeout": "Istek izravnog pinga - ponovni pokušaj {name} s preplavljanjem...",
|
||||
|
||||
"pingSuccessful": "Ping uspješan prema {name}{fallback}",
|
||||
|
||||
"viaFloodingFallback": " (putem rezervnog plavljenja)",
|
||||
"viaFloodingFallback": " (putem rezervnog preplavljanja)",
|
||||
|
||||
"pingFailed": "Ping neuspješan prema {name} - nije primljen odgovor",
|
||||
|
||||
@@ -543,6 +555,8 @@
|
||||
|
||||
"failed": "Neuspjelo",
|
||||
|
||||
"broadcast": "Emitirano",
|
||||
|
||||
"messageSentToPublicChannel": "Poruka poslana na javni kanal",
|
||||
|
||||
"pleaseSelectRoomToSendSar": "Molimo odaberite sobu za slanje SAR markera",
|
||||
|
||||
@@ -232,6 +232,26 @@
|
||||
"description": "Opzione lingua croata"
|
||||
},
|
||||
|
||||
"german": "Tedesco",
|
||||
"@german": {
|
||||
"description": "Opzione lingua tedesca"
|
||||
},
|
||||
|
||||
"spanish": "Spagnolo",
|
||||
"@spanish": {
|
||||
"description": "Opzione lingua spagnola"
|
||||
},
|
||||
|
||||
"french": "Francese",
|
||||
"@french": {
|
||||
"description": "Opzione lingua francese"
|
||||
},
|
||||
|
||||
"italian": "Italiano",
|
||||
"@italian": {
|
||||
"description": "Opzione lingua italiana"
|
||||
},
|
||||
|
||||
"locationBroadcasting": "Trasmissione Posizione",
|
||||
"@locationBroadcasting": {
|
||||
"description": "Intestazione sezione impostazioni posizione"
|
||||
@@ -639,6 +659,24 @@
|
||||
"description": "Descrizione per la permanenza dell'archiviazione nella stanza"
|
||||
},
|
||||
|
||||
"drawingsSentToPublicChannel": "{count} disegno{plural} mappa inviato al Canale Pubblico",
|
||||
"@drawingsSentToPublicChannel": {
|
||||
"description": "Messaggio di sistema quando i disegni vengono inviati al canale pubblico",
|
||||
"placeholders": {
|
||||
"count": {"type": "int"},
|
||||
"plural": {"type": "String"}
|
||||
}
|
||||
},
|
||||
|
||||
"drawingsSharedToPublicChannel": "{success}/{total} disegni condivisi sul Canale Pubblico",
|
||||
"@drawingsSharedToPublicChannel": {
|
||||
"description": "Messaggio snackbar che mostra il conteggio dei successi per i disegni condivisi sul canale pubblico",
|
||||
"placeholders": {
|
||||
"success": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
|
||||
"notConnectedToDevice": "Non connesso al dispositivo",
|
||||
"@notConnectedToDevice": {
|
||||
"description": "Messaggio di errore quando il dispositivo non è connesso per la messaggistica diretta"
|
||||
@@ -1715,6 +1753,11 @@
|
||||
"description": "Stato consegna: fallito"
|
||||
},
|
||||
|
||||
"broadcast": "Trasmissione",
|
||||
"@broadcast": {
|
||||
"description": "Stato di consegna per messaggi di canale (nessun eco ancora)"
|
||||
},
|
||||
|
||||
"sarMarkerFoundPerson": "Persona Trovata",
|
||||
"@sarMarkerFoundPerson": {
|
||||
"description": "Tipo marcatore SAR: persona trovata"
|
||||
|
||||
@@ -360,6 +360,30 @@ abstract class AppLocalizations {
|
||||
/// **'Croatian'**
|
||||
String get croatian;
|
||||
|
||||
/// German language option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'German'**
|
||||
String get german;
|
||||
|
||||
/// Spanish language option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spanish'**
|
||||
String get spanish;
|
||||
|
||||
/// French language option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'French'**
|
||||
String get french;
|
||||
|
||||
/// Italian language option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Italian'**
|
||||
String get italian;
|
||||
|
||||
/// Location settings section header
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -779,6 +803,18 @@ abstract class AppLocalizations {
|
||||
/// **'Stored permanently in room'**
|
||||
String get storedPermanently;
|
||||
|
||||
/// System message when drawings are sent to public channel
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sent {count} map drawing{plural} to Public Channel'**
|
||||
String drawingsSentToPublicChannel(int count, String plural);
|
||||
|
||||
/// Snackbar message showing success count for drawings shared to public channel
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shared {success}/{total} drawings to Public Channel'**
|
||||
String drawingsSharedToPublicChannel(int success, int total);
|
||||
|
||||
/// Error message when device is not connected for direct messaging
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1835,6 +1871,12 @@ abstract class AppLocalizations {
|
||||
/// **'Failed'**
|
||||
String get failed;
|
||||
|
||||
/// Delivery status for channel messages (no echoes yet)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Broadcast'**
|
||||
String get broadcast;
|
||||
|
||||
/// SAR marker type: found person
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -145,6 +145,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get croatian => 'Kroatisch';
|
||||
|
||||
@override
|
||||
String get german => 'Deutsch';
|
||||
|
||||
@override
|
||||
String get spanish => 'Spanisch';
|
||||
|
||||
@override
|
||||
String get french => 'Französisch';
|
||||
|
||||
@override
|
||||
String get italian => 'Italienisch';
|
||||
|
||||
@override
|
||||
String get locationBroadcasting => 'Standortübertragung';
|
||||
|
||||
@@ -388,6 +400,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get storedPermanently => 'Dauerhaft im Raum gespeichert';
|
||||
|
||||
@override
|
||||
String drawingsSentToPublicChannel(int count, String plural) {
|
||||
return '$count Kartenzeichnung$plural an öffentlichen Kanal gesendet';
|
||||
}
|
||||
|
||||
@override
|
||||
String drawingsSharedToPublicChannel(int success, int total) {
|
||||
return '$success/$total Zeichnungen mit öffentlichem Kanal geteilt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notConnectedToDevice => 'Nicht mit Gerät verbunden';
|
||||
|
||||
@@ -1009,6 +1031,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get failed => 'Fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get broadcast => 'Broadcast';
|
||||
|
||||
@override
|
||||
String get sarMarkerFoundPerson => 'Person gefunden';
|
||||
|
||||
|
||||
@@ -144,6 +144,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get croatian => 'Croatian';
|
||||
|
||||
@override
|
||||
String get german => 'German';
|
||||
|
||||
@override
|
||||
String get spanish => 'Spanish';
|
||||
|
||||
@override
|
||||
String get french => 'French';
|
||||
|
||||
@override
|
||||
String get italian => 'Italian';
|
||||
|
||||
@override
|
||||
String get locationBroadcasting => 'Location Broadcasting';
|
||||
|
||||
@@ -385,6 +397,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get storedPermanently => 'Stored permanently in room';
|
||||
|
||||
@override
|
||||
String drawingsSentToPublicChannel(int count, String plural) {
|
||||
return 'Sent $count map drawing$plural to Public Channel';
|
||||
}
|
||||
|
||||
@override
|
||||
String drawingsSharedToPublicChannel(int success, int total) {
|
||||
return 'Shared $success/$total drawings to Public Channel';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notConnectedToDevice => 'Not connected to device';
|
||||
|
||||
@@ -999,6 +1021,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get failed => 'Failed';
|
||||
|
||||
@override
|
||||
String get broadcast => 'Broadcast';
|
||||
|
||||
@override
|
||||
String get sarMarkerFoundPerson => 'Found Person';
|
||||
|
||||
|
||||
@@ -144,6 +144,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get croatian => 'Croata';
|
||||
|
||||
@override
|
||||
String get german => 'Alemán';
|
||||
|
||||
@override
|
||||
String get spanish => 'Español';
|
||||
|
||||
@override
|
||||
String get french => 'Francés';
|
||||
|
||||
@override
|
||||
String get italian => 'Italiano';
|
||||
|
||||
@override
|
||||
String get locationBroadcasting => 'Difusión de ubicación';
|
||||
|
||||
@@ -386,6 +398,16 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get storedPermanently => 'Almacenado permanentemente en la sala';
|
||||
|
||||
@override
|
||||
String drawingsSentToPublicChannel(int count, String plural) {
|
||||
return '$count dibujo$plural de mapa enviado al Canal Público';
|
||||
}
|
||||
|
||||
@override
|
||||
String drawingsSharedToPublicChannel(int success, int total) {
|
||||
return '$success/$total dibujos compartidos al Canal Público';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notConnectedToDevice => 'No conectado al dispositivo';
|
||||
|
||||
@@ -1005,6 +1027,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get failed => 'Fallido';
|
||||
|
||||
@override
|
||||
String get broadcast => 'Difusión';
|
||||
|
||||
@override
|
||||
String get sarMarkerFoundPerson => 'Persona encontrada';
|
||||
|
||||
|
||||
@@ -145,6 +145,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get croatian => 'Croate';
|
||||
|
||||
@override
|
||||
String get german => 'Allemand';
|
||||
|
||||
@override
|
||||
String get spanish => 'Espagnol';
|
||||
|
||||
@override
|
||||
String get french => 'Français';
|
||||
|
||||
@override
|
||||
String get italian => 'Italien';
|
||||
|
||||
@override
|
||||
String get locationBroadcasting => 'Diffusion de position';
|
||||
|
||||
@@ -389,6 +401,16 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get storedPermanently => 'Stocké de manière permanente dans le salon';
|
||||
|
||||
@override
|
||||
String drawingsSentToPublicChannel(int count, String plural) {
|
||||
return '$count dessin$plural de carte envoyé au Canal Public';
|
||||
}
|
||||
|
||||
@override
|
||||
String drawingsSharedToPublicChannel(int success, int total) {
|
||||
return '$success/$total dessins partagés sur le Canal Public';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notConnectedToDevice => 'Non connecté à l\'appareil';
|
||||
|
||||
@@ -1010,6 +1032,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get failed => 'Échec';
|
||||
|
||||
@override
|
||||
String get broadcast => 'Diffusion';
|
||||
|
||||
@override
|
||||
String get sarMarkerFoundPerson => 'Personne trouvée';
|
||||
|
||||
|
||||
@@ -144,6 +144,18 @@ class AppLocalizationsHr extends AppLocalizations {
|
||||
@override
|
||||
String get croatian => 'Hrvatski';
|
||||
|
||||
@override
|
||||
String get german => 'Njemački';
|
||||
|
||||
@override
|
||||
String get spanish => 'Španjolski';
|
||||
|
||||
@override
|
||||
String get french => 'Francuski';
|
||||
|
||||
@override
|
||||
String get italian => 'Talijanski';
|
||||
|
||||
@override
|
||||
String get locationBroadcasting => 'Emitiranje lokacije';
|
||||
|
||||
@@ -385,6 +397,16 @@ class AppLocalizationsHr extends AppLocalizations {
|
||||
@override
|
||||
String get storedPermanently => 'Trajno pohranjeno u sobi';
|
||||
|
||||
@override
|
||||
String drawingsSentToPublicChannel(int count, String plural) {
|
||||
return 'Poslano $count crtež$plural na javni kanal';
|
||||
}
|
||||
|
||||
@override
|
||||
String drawingsSharedToPublicChannel(int success, int total) {
|
||||
return 'Podijeljeno $success/$total crteža na javni kanal';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notConnectedToDevice => 'Nije povezano s uređajem';
|
||||
|
||||
@@ -494,7 +516,7 @@ class AppLocalizationsHr extends AppLocalizations {
|
||||
String get direct => 'Izravno';
|
||||
|
||||
@override
|
||||
String get flood => 'Poplava';
|
||||
String get flood => 'Preplavljanje';
|
||||
|
||||
@override
|
||||
String get admin => 'Administrator';
|
||||
@@ -515,12 +537,12 @@ class AppLocalizationsHr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String pingingFlood(String name) {
|
||||
return 'Pingiranje $name (poplava - nema puta)...';
|
||||
return 'Pingiranje $name (preplavljanje - nema puta)...';
|
||||
}
|
||||
|
||||
@override
|
||||
String directPingTimeout(String name) {
|
||||
return 'Istek izravnog pinga - ponovni pokušaj $name s poplavom...';
|
||||
return 'Istek izravnog pinga - ponovni pokušaj $name s preplavljanjem...';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -529,7 +551,7 @@ class AppLocalizationsHr extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get viaFloodingFallback => ' (putem rezervnog plavljenja)';
|
||||
String get viaFloodingFallback => ' (putem rezervnog preplavljanja)';
|
||||
|
||||
@override
|
||||
String pingFailed(String name) {
|
||||
@@ -1001,6 +1023,9 @@ class AppLocalizationsHr extends AppLocalizations {
|
||||
@override
|
||||
String get failed => 'Neuspjelo';
|
||||
|
||||
@override
|
||||
String get broadcast => 'Emitirano';
|
||||
|
||||
@override
|
||||
String get sarMarkerFoundPerson => 'Pronađena osoba';
|
||||
|
||||
|
||||
@@ -144,6 +144,18 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get croatian => 'Croato';
|
||||
|
||||
@override
|
||||
String get german => 'Tedesco';
|
||||
|
||||
@override
|
||||
String get spanish => 'Spagnolo';
|
||||
|
||||
@override
|
||||
String get french => 'Francese';
|
||||
|
||||
@override
|
||||
String get italian => 'Italiano';
|
||||
|
||||
@override
|
||||
String get locationBroadcasting => 'Trasmissione Posizione';
|
||||
|
||||
@@ -387,6 +399,16 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get storedPermanently => 'Archiviato permanentemente nella stanza';
|
||||
|
||||
@override
|
||||
String drawingsSentToPublicChannel(int count, String plural) {
|
||||
return '$count disegno$plural mappa inviato al Canale Pubblico';
|
||||
}
|
||||
|
||||
@override
|
||||
String drawingsSharedToPublicChannel(int success, int total) {
|
||||
return '$success/$total disegni condivisi sul Canale Pubblico';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notConnectedToDevice => 'Non connesso al dispositivo';
|
||||
|
||||
@@ -1007,6 +1029,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get failed => 'Fallito';
|
||||
|
||||
@override
|
||||
String get broadcast => 'Trasmissione';
|
||||
|
||||
@override
|
||||
String get sarMarkerFoundPerson => 'Persona Trovata';
|
||||
|
||||
|
||||
@@ -144,6 +144,18 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get croatian => 'Hrvaščina';
|
||||
|
||||
@override
|
||||
String get german => 'Nemščina';
|
||||
|
||||
@override
|
||||
String get spanish => 'Španščina';
|
||||
|
||||
@override
|
||||
String get french => 'Francoščina';
|
||||
|
||||
@override
|
||||
String get italian => 'Italijanščina';
|
||||
|
||||
@override
|
||||
String get locationBroadcasting => 'Oddajanje lokacije';
|
||||
|
||||
@@ -385,6 +397,16 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get storedPermanently => 'Trajno shranjeno v sobi';
|
||||
|
||||
@override
|
||||
String drawingsSentToPublicChannel(int count, String plural) {
|
||||
return 'Poslano $count risb$plural na javni kanal';
|
||||
}
|
||||
|
||||
@override
|
||||
String drawingsSharedToPublicChannel(int success, int total) {
|
||||
return 'Deljeno $success/$total risb na javni kanal';
|
||||
}
|
||||
|
||||
@override
|
||||
String get notConnectedToDevice => 'Ni povezano z napravo';
|
||||
|
||||
@@ -494,7 +516,7 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
String get direct => 'Neposredno';
|
||||
|
||||
@override
|
||||
String get flood => 'Poplava';
|
||||
String get flood => 'Razpršitev';
|
||||
|
||||
@override
|
||||
String get admin => 'Administrator';
|
||||
@@ -515,12 +537,12 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String pingingFlood(String name) {
|
||||
return 'Pinganje $name (poplava - brez poti)...';
|
||||
return 'Pinganje $name (razpršitev - brez poti)...';
|
||||
}
|
||||
|
||||
@override
|
||||
String directPingTimeout(String name) {
|
||||
return 'Časovna omejitev neposrednega pinga - ponovni poskus $name s poplavo...';
|
||||
return 'Časovna omejitev neposrednega pinga - ponovni poskus $name z razprševanjem...';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -529,7 +551,7 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get viaFloodingFallback => ' (preko rezervnega poplavljanja)';
|
||||
String get viaFloodingFallback => ' (preko rezervnega razprševanja)';
|
||||
|
||||
@override
|
||||
String pingFailed(String name) {
|
||||
@@ -1001,6 +1023,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
||||
@override
|
||||
String get failed => 'Neuspešno';
|
||||
|
||||
@override
|
||||
String get broadcast => 'Oddajano';
|
||||
|
||||
@override
|
||||
String get sarMarkerFoundPerson => 'Najdena oseba';
|
||||
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
|
||||
"croatian": "Hrvaščina",
|
||||
|
||||
"german": "Nemščina",
|
||||
|
||||
"spanish": "Španščina",
|
||||
|
||||
"french": "Francoščina",
|
||||
|
||||
"italian": "Italijanščina",
|
||||
|
||||
"locationBroadcasting": "Oddajanje lokacije",
|
||||
|
||||
"autoLocationTracking": "Samodejno sledenje lokaciji",
|
||||
@@ -223,6 +231,10 @@
|
||||
|
||||
"storedPermanently": "Trajno shranjeno v sobi",
|
||||
|
||||
"drawingsSentToPublicChannel": "Poslano {count} risb{plural} na javni kanal",
|
||||
|
||||
"drawingsSharedToPublicChannel": "Deljeno {success}/{total} risb na javni kanal",
|
||||
|
||||
"notConnectedToDevice": "Ni povezano z napravo",
|
||||
|
||||
"directMessage": "Neposredno sporočilo",
|
||||
@@ -285,7 +297,7 @@
|
||||
|
||||
"direct": "Neposredno",
|
||||
|
||||
"flood": "Poplava",
|
||||
"flood": "Razpršitev",
|
||||
|
||||
"admin": "Administrator",
|
||||
|
||||
@@ -297,13 +309,13 @@
|
||||
|
||||
"pingingDirect": "Pinganje {name} (neposredno preko poti)...",
|
||||
|
||||
"pingingFlood": "Pinganje {name} (poplava - brez poti)...",
|
||||
"pingingFlood": "Pinganje {name} (razpršitev - brez poti)...",
|
||||
|
||||
"directPingTimeout": "Časovna omejitev neposrednega pinga - ponovni poskus {name} s poplavo...",
|
||||
"directPingTimeout": "Časovna omejitev neposrednega pinga - ponovni poskus {name} z razprševanjem...",
|
||||
|
||||
"pingSuccessful": "Ping uspešen do {name}{fallback}",
|
||||
|
||||
"viaFloodingFallback": " (preko rezervnega poplavljanja)",
|
||||
"viaFloodingFallback": " (preko rezervnega razprševanja)",
|
||||
|
||||
"pingFailed": "Ping neuspešen do {name} - odgovor ni prejet",
|
||||
|
||||
@@ -543,6 +555,8 @@
|
||||
|
||||
"failed": "Neuspešno",
|
||||
|
||||
"broadcast": "Oddajano",
|
||||
|
||||
"messageSentToPublicChannel": "Sporočilo poslano na javni kanal",
|
||||
|
||||
"pleaseSelectRoomToSendSar": "Prosimo, izberite sobo za pošiljanje SAR označevalca",
|
||||
|
||||
@@ -214,10 +214,14 @@ class Contact {
|
||||
return advName.substring(emoji.length).trim();
|
||||
}
|
||||
|
||||
/// Check if this contact is the Public Channel (all-zeros public key)
|
||||
bool get isPublicChannel =>
|
||||
publicKeyHex == '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
/// Get localized display name (for Public Channel and other special contacts)
|
||||
String getLocalizedDisplayName(BuildContext context) {
|
||||
// Check if this is the Public Channel (all-zeros public key)
|
||||
if (publicKeyHex == '0000000000000000000000000000000000000000000000000000000000000000') {
|
||||
if (isPublicChannel) {
|
||||
return AppLocalizations.of(context)!.publicChannel;
|
||||
}
|
||||
// For all other contacts, use the regular display name
|
||||
@@ -226,24 +230,28 @@ class Contact {
|
||||
|
||||
/// Check if contact has a learned routing path
|
||||
/// When true, messages will use direct routing. When false, messages will use flood mode.
|
||||
bool get hasPath => outPathLen > 0 && outPathLen <= 64;
|
||||
/// outPathLen: -1 = unknown/not learned, 0 = direct (zero hops), 1+ = multi-hop path
|
||||
bool get hasPath => outPathLen >= 0 && outPathLen <= 64;
|
||||
|
||||
/// Get path description for UI display
|
||||
String get pathDescription {
|
||||
if (!hasPath) {
|
||||
// -1 (0xFF) indicates path not learned yet
|
||||
return 'No path (flood mode)';
|
||||
}
|
||||
|
||||
// outPathLen includes the number of hops in the path
|
||||
final hops = outPathLen;
|
||||
if (hops == 1) {
|
||||
// outPathLen = 0 means direct connection with zero hops
|
||||
// outPathLen >= 1 means path with N hops
|
||||
if (outPathLen == 0) {
|
||||
return 'Direct (0 hops)';
|
||||
} else if (hops <= 3) {
|
||||
return 'Good path (${hops - 1} hop${hops - 1 > 1 ? 's' : ''})';
|
||||
} else if (hops <= 5) {
|
||||
return 'Medium path (${hops - 1} hops)';
|
||||
} else if (outPathLen == 1) {
|
||||
return 'Direct (1 hop)';
|
||||
} else if (outPathLen <= 3) {
|
||||
return 'Good path ($outPathLen hops)';
|
||||
} else if (outPathLen <= 5) {
|
||||
return 'Medium path ($outPathLen hops)';
|
||||
} else {
|
||||
return 'Long path (${hops - 1} hops)';
|
||||
return 'Long path ($outPathLen hops)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,11 +259,11 @@ class Contact {
|
||||
/// -1 means no path (will use flood mode)
|
||||
int get pathQuality {
|
||||
if (!hasPath) return -1;
|
||||
if (outPathLen == 1) return 5; // Direct connection (0 hops)
|
||||
if (outPathLen <= 2) return 4; // 1 hop
|
||||
if (outPathLen <= 3) return 3; // 2 hops
|
||||
if (outPathLen <= 4) return 2; // 3 hops
|
||||
if (outPathLen <= 5) return 1; // 4 hops
|
||||
if (outPathLen == 0) return 5; // Direct connection (0 hops)
|
||||
if (outPathLen == 1) return 4; // 1 hop
|
||||
if (outPathLen <= 2) return 3; // 2 hops
|
||||
if (outPathLen <= 3) return 2; // 3 hops
|
||||
if (outPathLen <= 4) return 1; // 4 hops
|
||||
return 0; // 5+ hops
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ class Message {
|
||||
// Read status tracking
|
||||
final bool isRead; // Whether message has been read by user
|
||||
|
||||
// Echo detection for public channel messages
|
||||
final int echoCount; // Number of times message was detected being rebroadcast
|
||||
final DateTime? firstEchoAt; // When first echo was detected
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.messageType,
|
||||
@@ -97,6 +101,8 @@ class Message {
|
||||
this.lastRetryAt,
|
||||
this.usedFloodFallback = false,
|
||||
this.isRead = false,
|
||||
this.echoCount = 0,
|
||||
this.firstEchoAt,
|
||||
});
|
||||
|
||||
/// Get sender public key as hex string
|
||||
@@ -176,8 +182,26 @@ class Message {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get echo status text for channel messages
|
||||
String get echoStatusText {
|
||||
if (!isChannelMessage) return '';
|
||||
|
||||
if (echoCount == 0) {
|
||||
return 'Broadcast (no echoes)';
|
||||
} else if (echoCount == 1) {
|
||||
return 'Rebroadcast by 1 node';
|
||||
} else {
|
||||
return 'Rebroadcast by $echoCount nodes';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get friendly delivery status description
|
||||
String get deliveryStatusText {
|
||||
// For channel messages, show echo status instead
|
||||
if (isChannelMessage && isSentMessage) {
|
||||
return echoStatusText;
|
||||
}
|
||||
|
||||
switch (deliveryStatus) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
if (retryAttempt > 0) {
|
||||
@@ -263,6 +287,8 @@ class Message {
|
||||
DateTime? lastRetryAt,
|
||||
bool? usedFloodFallback,
|
||||
bool? isRead,
|
||||
int? echoCount,
|
||||
DateTime? firstEchoAt,
|
||||
}) {
|
||||
return Message(
|
||||
id: id ?? this.id,
|
||||
@@ -289,6 +315,8 @@ class Message {
|
||||
lastRetryAt: lastRetryAt ?? this.lastRetryAt,
|
||||
usedFloodFallback: usedFloodFallback ?? this.usedFloodFallback,
|
||||
isRead: isRead ?? this.isRead,
|
||||
echoCount: echoCount ?? this.echoCount,
|
||||
firstEchoAt: firstEchoAt ?? this.firstEchoAt,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
83
lib/models/sent_message_tracker.dart
Normal file
83
lib/models/sent_message_tracker.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Tracks sent public channel messages for echo detection
|
||||
///
|
||||
/// When a message is sent to the public channel, it's encrypted with AES128-ECB
|
||||
/// which is deterministic. When another node receives and rebroadcasts it,
|
||||
/// the raw packet will be byte-for-byte identical. We can detect these echoes
|
||||
/// by comparing the raw packet data from PUSH_CODE_LOG_RX_DATA (0x88) against
|
||||
/// packets we've sent.
|
||||
class SentMessageTracker {
|
||||
/// Unique identifier for the message (timestamp-based)
|
||||
final String messageId;
|
||||
|
||||
/// SHA256 hash of the encrypted packet for fast O(1) lookup
|
||||
final String packetHashHex;
|
||||
|
||||
/// Original raw encrypted packet bytes (for verification)
|
||||
final Uint8List? rawPacket;
|
||||
|
||||
/// When the message was sent
|
||||
final DateTime sentTime;
|
||||
|
||||
/// When this tracker expires (default: 5 minutes)
|
||||
final DateTime expiryTime;
|
||||
|
||||
/// Number of times we've detected this message being rebroadcast
|
||||
int echoCount;
|
||||
|
||||
/// Unique echo paths detected (SNR/RSSI signatures)
|
||||
/// Format: "snr_rssi" e.g., "20_-56" means SNR=5.0dB (20/4), RSSI=-56dBm
|
||||
final Set<String> uniqueEchoPaths;
|
||||
|
||||
/// Timestamps when echoes were detected
|
||||
final List<DateTime> echoTimestamps;
|
||||
|
||||
SentMessageTracker({
|
||||
required this.messageId,
|
||||
required this.packetHashHex,
|
||||
this.rawPacket,
|
||||
required this.sentTime,
|
||||
required this.expiryTime,
|
||||
this.echoCount = 0,
|
||||
Set<String>? uniqueEchoPaths,
|
||||
List<DateTime>? echoTimestamps,
|
||||
}) : uniqueEchoPaths = uniqueEchoPaths ?? {},
|
||||
echoTimestamps = echoTimestamps ?? [];
|
||||
|
||||
/// Check if this tracker has expired
|
||||
bool get isExpired => DateTime.now().isAfter(expiryTime);
|
||||
|
||||
/// Time until expiry
|
||||
Duration get timeUntilExpiry => expiryTime.difference(DateTime.now());
|
||||
|
||||
/// Add an echo detection
|
||||
void addEcho(int snrRaw, int rssiDbm) {
|
||||
echoCount++;
|
||||
uniqueEchoPaths.add('${snrRaw}_$rssiDbm');
|
||||
echoTimestamps.add(DateTime.now());
|
||||
}
|
||||
|
||||
/// Get the SNR in dB from raw value
|
||||
static double snrRawToDb(int snrRaw) {
|
||||
return snrRaw.toSigned(8) / 4.0;
|
||||
}
|
||||
|
||||
/// Get formatted echo statistics
|
||||
String get echoStats {
|
||||
if (echoCount == 0) return 'No echoes detected';
|
||||
if (echoCount == 1) return '1 echo from ${uniqueEchoPaths.length} path(s)';
|
||||
return '$echoCount echoes from ${uniqueEchoPaths.length} path(s)';
|
||||
}
|
||||
|
||||
/// Get average time to first echo
|
||||
Duration? get timeToFirstEcho {
|
||||
if (echoTimestamps.isEmpty) return null;
|
||||
return echoTimestamps.first.difference(sentTime);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SentMessageTracker(id=$messageId, echoes=$echoCount, paths=${uniqueEchoPaths.length}, expired=$isExpired)';
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,12 @@ class AppProvider with ChangeNotifier {
|
||||
messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs);
|
||||
};
|
||||
|
||||
// When an echo is detected for a public channel message (PUSH_CODE_LOG_RX_DATA matched)
|
||||
connectionProvider.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
|
||||
debugPrint('🔊 [AppProvider] Echo detected - Message: $messageId, Count: $echoCount');
|
||||
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
|
||||
};
|
||||
|
||||
// Wire up MessagesProvider's sendMessageCallback for retry logic
|
||||
messagesProvider.sendMessageCallback = ({
|
||||
required contactPublicKey,
|
||||
@@ -284,7 +290,7 @@ class AppProvider with ChangeNotifier {
|
||||
try {
|
||||
// Get all room contacts (excluding Public Channel)
|
||||
final rooms = contactsProvider.rooms
|
||||
.where((room) => room.advName != 'Public Channel')
|
||||
.where((room) => !room.isPublicChannel)
|
||||
.toList();
|
||||
|
||||
if (rooms.isEmpty) {
|
||||
|
||||
@@ -133,6 +133,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
|
||||
onMessageSent;
|
||||
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
|
||||
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
|
||||
onMessageEchoDetected;
|
||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||
|
||||
// Track pending send operations for auto-recovery
|
||||
@@ -381,6 +383,13 @@ class ConnectionProvider with ChangeNotifier {
|
||||
onMessageDelivered?.call(ackCode, roundTripTimeMs);
|
||||
};
|
||||
|
||||
_bleService.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
|
||||
print(
|
||||
'🔊 [Provider] Echo detected - Message: $messageId, Count: $echoCount',
|
||||
);
|
||||
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
|
||||
};
|
||||
|
||||
_bleService.onStatusResponse = (publicKeyPrefix, statusData) {
|
||||
print('📥 [Provider] Status response received from node');
|
||||
print(
|
||||
@@ -774,8 +783,16 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('📨 [ConnectionProvider] sendChannelMessage called:');
|
||||
debugPrint(' Channel: $channelIdx');
|
||||
debugPrint(' Text: $text');
|
||||
debugPrint(' MessageID: $messageId');
|
||||
|
||||
await _bleService.sendChannelMessage(channelIdx: channelIdx, text: text);
|
||||
|
||||
debugPrint('✅ [ConnectionProvider] BLE send completed');
|
||||
debugPrint(' Checking messageId: ${messageId != null ? "Present ($messageId)" : "NULL"}');
|
||||
|
||||
// Channel messages are ephemeral (not persisted) - mark as "sent" immediately
|
||||
// They don't have ACK/TAG mechanism like direct messages
|
||||
if (messageId != null) {
|
||||
@@ -783,6 +800,12 @@ class ConnectionProvider with ChangeNotifier {
|
||||
print(' Message ID: $messageId');
|
||||
print(' onMessageSent callback exists: ${onMessageSent != null}');
|
||||
|
||||
// Track for echo detection
|
||||
// The BLE handler will capture the packet via LOG_RX_DATA and associate it
|
||||
debugPrint(' Calling trackSentChannelMessage...');
|
||||
_bleService.trackSentChannelMessage(messageId);
|
||||
debugPrint(' trackSentChannelMessage completed');
|
||||
|
||||
// Small delay to ensure the message is in the MessagesProvider list
|
||||
// before we try to mark it as sent
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
@@ -602,6 +602,36 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle echo detection for public channel messages
|
||||
void handleMessageEcho(String messageId, int echoCount, int snrRaw, int rssiDbm) {
|
||||
print('🔊 [MessagesProvider] handleMessageEcho called');
|
||||
print(' Message ID: $messageId');
|
||||
print(' Echo count: $echoCount');
|
||||
print(' SNR: ${(snrRaw.toSigned(8) / 4.0).toStringAsFixed(2)} dB');
|
||||
print(' RSSI: ${rssiDbm.toSigned(8)} dBm');
|
||||
|
||||
// Find the message
|
||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (index != -1) {
|
||||
final message = _messages[index];
|
||||
print(' ✅ Found message: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...');
|
||||
|
||||
// Update echo count
|
||||
final updatedMessage = message.copyWith(
|
||||
echoCount: echoCount,
|
||||
firstEchoAt: message.firstEchoAt ?? DateTime.now(),
|
||||
);
|
||||
_messages[index] = updatedMessage;
|
||||
|
||||
print(' Updated echo count to: $echoCount');
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
print(' ✅ Echo update complete, UI notified');
|
||||
} else {
|
||||
print(' ⚠️ Message not found in messages list');
|
||||
}
|
||||
}
|
||||
|
||||
/// Update message status to delivered with RTT
|
||||
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
||||
print('🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms');
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../models/ble_packet_log.dart';
|
||||
import '../../models/sent_message_tracker.dart';
|
||||
import '../buffer_reader.dart';
|
||||
import '../meshcore_constants.dart';
|
||||
import '../meshcore_opcode_names.dart';
|
||||
@@ -33,6 +34,7 @@ typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb,
|
||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||
typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName);
|
||||
typedef OnMessageEchoDetectedCallback = void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
|
||||
|
||||
/// Processes incoming responses from the BLE device
|
||||
class BleResponseHandler {
|
||||
@@ -45,6 +47,11 @@ class BleResponseHandler {
|
||||
// Reference to command queue for completing pending commands
|
||||
BleCommandQueue? _commandQueue;
|
||||
|
||||
// Echo detection for public channel messages
|
||||
final Map<String, SentMessageTracker> _sentMessageTrackers = {};
|
||||
static const int _maxTrackers = 100;
|
||||
static const Duration _trackerTTL = Duration(minutes: 5);
|
||||
|
||||
// Callbacks
|
||||
OnContactCallback? onContactReceived;
|
||||
OnContactsCompleteCallback? onContactsComplete;
|
||||
@@ -66,6 +73,7 @@ class BleResponseHandler {
|
||||
OnErrorCallback? onError;
|
||||
OnContactNotFoundCallback? onContactNotFound;
|
||||
OnChannelInfoCallback? onChannelInfoReceived;
|
||||
OnMessageEchoDetectedCallback? onMessageEchoDetected;
|
||||
VoidCallback? onRxActivity;
|
||||
|
||||
// Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND
|
||||
@@ -436,6 +444,12 @@ class BleResponseHandler {
|
||||
final entropy = uniqueBytes / rawPacketData.length;
|
||||
final isLikelyEncrypted = entropy > 0.7;
|
||||
|
||||
// First, try to associate this packet with a recently sent message (within 2s)
|
||||
_associatePacketWithSentMessage(rawPacketData);
|
||||
|
||||
// Then, check if this packet matches any sent message (echo detection)
|
||||
_checkForEcho(rawPacketData, snrRaw, rssiDbm);
|
||||
|
||||
// Create decoded info for packet log (includes SNR and RSSI)
|
||||
final logRxDataInfo = LogRxDataInfo(
|
||||
entropy: entropy,
|
||||
@@ -465,6 +479,246 @@ class BleResponseHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple hash function for packet identification (replaces SHA256)
|
||||
String _simplePacketHash(Uint8List packet) {
|
||||
// Use a simple hash based on packet length and first/last bytes
|
||||
// This is sufficient for short-lived echo detection (5 min TTL)
|
||||
if (packet.isEmpty) return '0';
|
||||
|
||||
int hash = packet.length;
|
||||
// Mix in bytes from start, middle, and end
|
||||
for (int i = 0; i < packet.length && i < 8; i++) {
|
||||
hash = ((hash << 5) - hash) + packet[i];
|
||||
hash = hash & 0xFFFFFFFF; // Keep 32-bit
|
||||
}
|
||||
if (packet.length > 16) {
|
||||
for (int i = packet.length ~/ 2; i < packet.length ~/ 2 + 8 && i < packet.length; i++) {
|
||||
hash = ((hash << 5) - hash) + packet[i];
|
||||
hash = hash & 0xFFFFFFFF;
|
||||
}
|
||||
}
|
||||
if (packet.length > 8) {
|
||||
for (int i = packet.length - 8; i < packet.length; i++) {
|
||||
hash = ((hash << 5) - hash) + packet[i];
|
||||
hash = hash & 0xFFFFFFFF;
|
||||
}
|
||||
}
|
||||
return hash.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
|
||||
/// Check if received packet is an echo of a sent message
|
||||
void _checkForEcho(Uint8List rawPacket, int snrRaw, int rssiDbm) {
|
||||
try {
|
||||
// Need at least header + path_len
|
||||
if (rawPacket.length < 2) return;
|
||||
|
||||
final header = rawPacket[0];
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
if (payloadType != 0x05) return; // Only track GRP_TXT
|
||||
|
||||
final pathLen = rawPacket[1];
|
||||
if (pathLen == 0 || rawPacket.length < 2 + pathLen) return;
|
||||
|
||||
// Extract path for unique echo tracking
|
||||
final path = rawPacket.sublist(2, 2 + pathLen);
|
||||
final pathSignature = path.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
|
||||
// Check if our node hash is in the path (meaning this is our message being rebroadcast)
|
||||
final containsOurHash = _ourNodeHash != null && path.contains(_ourNodeHash!);
|
||||
if (!containsOurHash) {
|
||||
// This packet doesn't have our hash in the path, so it's not our message
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract encrypted payload
|
||||
final payloadStart = 2 + pathLen;
|
||||
final encryptedPayload = rawPacket.sublist(payloadStart);
|
||||
final payloadHash = _simplePacketHash(encryptedPayload);
|
||||
|
||||
// Check if we have a matching sent message (by payload hash)
|
||||
final tracker = _sentMessageTrackers[payloadHash];
|
||||
if (tracker != null && !tracker.isExpired) {
|
||||
// Check if this is a NEW path (different from already seen paths)
|
||||
if (!tracker.uniqueEchoPaths.contains(pathSignature)) {
|
||||
// New echo detected via different path!
|
||||
tracker.uniqueEchoPaths.add(pathSignature);
|
||||
tracker.echoCount++;
|
||||
tracker.echoTimestamps.add(DateTime.now());
|
||||
|
||||
print(' 🔊 [Echo] New echo detected!');
|
||||
print(' Message: ${tracker.messageId}');
|
||||
print(' Path: $pathSignature');
|
||||
print(' Total echoes: ${tracker.echoCount}');
|
||||
print(' Unique paths: ${tracker.uniqueEchoPaths.length}');
|
||||
|
||||
// Notify callback
|
||||
onMessageEchoDetected?.call(tracker.messageId, tracker.echoCount, snrRaw, rssiDbm);
|
||||
} else {
|
||||
print(' ♻️ [Echo] Duplicate path (already counted): $pathSignature');
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup expired trackers
|
||||
_cleanupExpiredTrackers();
|
||||
} catch (e) {
|
||||
print(' ⚠️ [Echo] Error checking for echo: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Track a sent public channel message for echo detection
|
||||
///
|
||||
/// NEW STRATEGY: Since firmware doesn't log our own transmissions,
|
||||
/// we track ANY GRP_TXT packets that arrive shortly after sending.
|
||||
/// The first packet with matching encrypted payload is likely our message,
|
||||
/// and subsequent packets with the same payload are echoes.
|
||||
void trackSentMessage(String messageId, Uint8List? rawPacket) {
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final tracker = SentMessageTracker(
|
||||
messageId: messageId,
|
||||
packetHashHex: 'pending', // Will be filled when we capture ANY packet
|
||||
rawPacket: null,
|
||||
sentTime: now,
|
||||
expiryTime: now.add(_trackerTTL),
|
||||
);
|
||||
|
||||
// Store by message ID temporarily
|
||||
_sentMessageTrackers[messageId] = tracker;
|
||||
print(' 📤 [Echo] Tracking message $messageId (will match any GRP_TXT within 2000ms)');
|
||||
print(' 📊 [Echo] Total trackers: ${_sentMessageTrackers.length}');
|
||||
|
||||
// Cleanup if too many trackers
|
||||
if (_sentMessageTrackers.length > _maxTrackers) {
|
||||
_cleanupOldestTrackers();
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ⚠️ [Echo] Error tracking sent message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Store our node hash (first byte of our public key) for sender identification
|
||||
int? _ourNodeHash;
|
||||
|
||||
/// Set our node hash for packet identification
|
||||
void setOurNodeHash(int nodeHash) {
|
||||
_ourNodeHash = nodeHash;
|
||||
print(' 🔑 [Echo] Our node hash set to: 0x${nodeHash.toRadixString(16).padLeft(2, '0')}');
|
||||
print(' ℹ️ [Echo] Will track packets containing our hash in the path');
|
||||
}
|
||||
|
||||
/// Associate a captured packet with a sent message
|
||||
///
|
||||
/// NEW STRATEGY: Firmware doesn't log our own transmissions, only echoes!
|
||||
/// So we capture the FIRST GRP_TXT packet after sending (likely an echo),
|
||||
/// then count additional instances of the same packet payload.
|
||||
///
|
||||
/// Packet structure for GRP_TXT:
|
||||
/// [0] = header (route type + payload type + version)
|
||||
/// [1] = path_len
|
||||
/// [2] = path[0] = sender's node hash
|
||||
/// [3+] = rest of path + encrypted payload
|
||||
void _associatePacketWithSentMessage(Uint8List rawPacket) {
|
||||
try {
|
||||
// Need at least 3 bytes: header + path_len + first path byte
|
||||
if (rawPacket.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a GRP_TXT packet (payload type = 0x05)
|
||||
final header = rawPacket[0];
|
||||
final payloadType = (header >> 2) & 0x0F;
|
||||
if (payloadType != 0x05) { // Not a group message
|
||||
return;
|
||||
}
|
||||
|
||||
final pathLen = rawPacket[1];
|
||||
if (pathLen == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// Extract the path from the packet for unique echo tracking
|
||||
final path = rawPacket.sublist(2, 2 + pathLen);
|
||||
final pathSignature = path.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
|
||||
// Check if our node hash is in the path (meaning this is our message being rebroadcast)
|
||||
final containsOurHash = _ourNodeHash != null && path.contains(_ourNodeHash!);
|
||||
if (!containsOurHash) {
|
||||
// This packet doesn't have our hash in the path, so it's not our message
|
||||
return;
|
||||
}
|
||||
|
||||
print(' ✅ [Echo] Packet contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) in path: $pathSignature');
|
||||
|
||||
// Extract encrypted payload (everything after path)
|
||||
final payloadStart = 2 + pathLen;
|
||||
final encryptedPayload = rawPacket.sublist(payloadStart);
|
||||
// Hash only the encrypted payload to identify the same message
|
||||
final payloadHash = _simplePacketHash(encryptedPayload);
|
||||
|
||||
// Find pending trackers (within 2000ms window)
|
||||
for (final entry in _sentMessageTrackers.entries.toList()) {
|
||||
final tracker = entry.value;
|
||||
if (tracker.packetHashHex != 'pending') continue;
|
||||
|
||||
final timeSinceSent = now.difference(tracker.sentTime);
|
||||
if (timeSinceSent.inMilliseconds > 2000) continue; // Outside window
|
||||
|
||||
// This is the FIRST packet we see after sending - associate it!
|
||||
// Remove old entry by message ID
|
||||
_sentMessageTrackers.remove(entry.key);
|
||||
|
||||
// Create updated tracker stored by payload hash
|
||||
final updatedTracker = SentMessageTracker(
|
||||
messageId: tracker.messageId,
|
||||
packetHashHex: payloadHash, // Use payload hash to identify message
|
||||
rawPacket: rawPacket,
|
||||
sentTime: tracker.sentTime,
|
||||
expiryTime: tracker.expiryTime,
|
||||
echoCount: 1, // This first packet counts as an echo
|
||||
uniqueEchoPaths: {pathSignature}, // Track unique paths
|
||||
echoTimestamps: [now],
|
||||
);
|
||||
|
||||
_sentMessageTrackers[payloadHash] = updatedTracker;
|
||||
print(' 📦 [Echo] Captured packet for tracking!');
|
||||
print(' Message ID: ${tracker.messageId}');
|
||||
print(' Path: $pathSignature');
|
||||
print(' Time delta: ${timeSinceSent.inMilliseconds}ms');
|
||||
print(' Payload hash: $payloadHash');
|
||||
print(' Echo count: 1 (first detection)');
|
||||
|
||||
// Notify immediately that we have 1 echo
|
||||
onMessageEchoDetected?.call(tracker.messageId, 1, 0, 0);
|
||||
break; // Only associate with first pending tracker
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ⚠️ [Echo] Error associating packet: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove expired trackers
|
||||
void _cleanupExpiredTrackers() {
|
||||
_sentMessageTrackers.removeWhere((key, tracker) => tracker.isExpired);
|
||||
}
|
||||
|
||||
/// Remove oldest trackers when limit exceeded
|
||||
void _cleanupOldestTrackers() {
|
||||
if (_sentMessageTrackers.length <= _maxTrackers) return;
|
||||
|
||||
// Sort by sent time and remove oldest
|
||||
final sortedEntries = _sentMessageTrackers.entries.toList()
|
||||
..sort((a, b) => a.value.sentTime.compareTo(b.value.sentTime));
|
||||
|
||||
final toRemove = sortedEntries.take(_sentMessageTrackers.length - _maxTrackers);
|
||||
for (final entry in toRemove) {
|
||||
_sentMessageTrackers.remove(entry.key);
|
||||
}
|
||||
|
||||
print(' 🧹 [Echo] Cleaned up ${toRemove.length} old trackers');
|
||||
}
|
||||
|
||||
/// Handle NewAdvert push
|
||||
void _handleNewAdvert(BufferReader reader) {
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,10 @@ class LocalePreferences {
|
||||
Locale('en'), // English
|
||||
Locale('sl'), // Slovenian
|
||||
Locale('hr'), // Croatian
|
||||
Locale('de'), // German
|
||||
Locale('es'), // Spanish
|
||||
Locale('fr'), // French
|
||||
Locale('it'), // Italian
|
||||
];
|
||||
|
||||
/// Get the saved locale or return null to use system locale
|
||||
@@ -49,6 +53,14 @@ class LocalePreferences {
|
||||
return 'Slovenščina';
|
||||
case 'hr':
|
||||
return 'Hrvatski';
|
||||
case 'de':
|
||||
return 'Deutsch';
|
||||
case 'es':
|
||||
return 'Español';
|
||||
case 'fr':
|
||||
return 'Français';
|
||||
case 'it':
|
||||
return 'Italiano';
|
||||
default:
|
||||
return locale.languageCode;
|
||||
}
|
||||
@@ -63,6 +75,14 @@ class LocalePreferences {
|
||||
return 'Slovenščina';
|
||||
case 'hr':
|
||||
return 'Hrvatski';
|
||||
case 'de':
|
||||
return 'Deutsch';
|
||||
case 'es':
|
||||
return 'Español';
|
||||
case 'fr':
|
||||
return 'Français';
|
||||
case 'it':
|
||||
return 'Italiano';
|
||||
default:
|
||||
return locale.languageCode;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey);
|
||||
typedef OnPathUpdatedCallback = void Function(Uint8List publicKey);
|
||||
typedef OnMessageSentCallback = void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode);
|
||||
typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTimeMs);
|
||||
typedef OnMessageEchoDetectedCallback = void Function(String messageId, int echoCount, int snrRaw, int rssiDbm);
|
||||
typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData);
|
||||
typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
|
||||
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
|
||||
@@ -61,6 +62,7 @@ class MeshCoreBleService {
|
||||
OnPathUpdatedCallback? onPathUpdated;
|
||||
OnMessageSentCallback? onMessageSent;
|
||||
OnMessageDeliveredCallback? onMessageDelivered;
|
||||
OnMessageEchoDetectedCallback? onMessageEchoDetected;
|
||||
OnStatusResponseCallback? onStatusResponse;
|
||||
OnBinaryResponseCallback? onBinaryResponse;
|
||||
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
||||
@@ -116,6 +118,13 @@ class MeshCoreBleService {
|
||||
onTelemetryReceived?.call(publicKey, lppData);
|
||||
};
|
||||
_responseHandler.onSelfInfoReceived = (selfInfo) {
|
||||
// Extract our node hash (first byte of public key) for echo detection
|
||||
if (selfInfo['publicKey'] != null) {
|
||||
final publicKey = selfInfo['publicKey'] as Uint8List;
|
||||
if (publicKey.isNotEmpty) {
|
||||
_responseHandler.setOurNodeHash(publicKey[0]);
|
||||
}
|
||||
}
|
||||
onSelfInfoReceived?.call(selfInfo);
|
||||
};
|
||||
_responseHandler.onDeviceInfoReceived = (deviceInfo) {
|
||||
@@ -145,6 +154,9 @@ class MeshCoreBleService {
|
||||
_responseHandler.onMessageDelivered = (ackCode, roundTripTimeMs) {
|
||||
onMessageDelivered?.call(ackCode, roundTripTimeMs);
|
||||
};
|
||||
_responseHandler.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) {
|
||||
onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm);
|
||||
};
|
||||
_responseHandler.onStatusResponse = (publicKeyPrefix, statusData) {
|
||||
onStatusResponse?.call(publicKeyPrefix, statusData);
|
||||
};
|
||||
@@ -297,6 +309,12 @@ class MeshCoreBleService {
|
||||
}
|
||||
|
||||
/// Send flood-mode text message to channel
|
||||
/// Track a sent channel message for echo detection
|
||||
void trackSentChannelMessage(String messageId) {
|
||||
debugPrint('🔵 [MeshCoreBleService] trackSentChannelMessage called for: $messageId');
|
||||
_responseHandler.trackSentMessage(messageId, null);
|
||||
}
|
||||
|
||||
Future<void> sendChannelMessage({
|
||||
required int channelIdx,
|
||||
required String text,
|
||||
|
||||
@@ -8,6 +8,17 @@ extension MessageLocalization on Message {
|
||||
String getLocalizedDeliveryStatus(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
// For channel messages, show echo count instead of delivery status
|
||||
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
||||
if (echoCount == 0) {
|
||||
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
||||
} else if (echoCount == 1) {
|
||||
return 'Rebroadcast by 1 node';
|
||||
} else {
|
||||
return 'Rebroadcast by $echoCount nodes';
|
||||
}
|
||||
}
|
||||
|
||||
switch (deliveryStatus) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
return l10n.sending;
|
||||
|
||||
@@ -719,7 +719,7 @@ class ContactTile extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
// Room Login button for room contacts (except Public Channel)
|
||||
if (contact.type == ContactType.room && contact.advName != 'Public Channel') ...[
|
||||
if (contact.type == ContactType.room && !contact.isPublicChannel) ...[
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -739,7 +739,7 @@ class ContactTile extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
// Delete Contact button (for all contact types except Public Channel)
|
||||
if (contact.advName != 'Public Channel') ...[
|
||||
if (!contact.isPublicChannel) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
|
||||
@@ -482,15 +482,16 @@ class DrawingToolbar extends StatelessWidget {
|
||||
|
||||
// Add informational message to chat
|
||||
final messagesProvider = Provider.of<MessagesProvider>(context, listen: false);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
messagesProvider.logSystemMessage(
|
||||
text: '📤 Sent ${drawings.length} map drawing${drawings.length > 1 ? 's' : ''} to Public Channel',
|
||||
text: '📤 ${l10n.drawingsSentToPublicChannel(drawings.length, drawings.length > 1 ? 's' : '')}',
|
||||
level: 'info',
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Shared $successCount/${drawings.length} drawings to Public Channel',
|
||||
l10n.drawingsSharedToPublicChannel(successCount, drawings.length),
|
||||
),
|
||||
backgroundColor: successCount == drawings.length
|
||||
? Colors.green
|
||||
|
||||
Reference in New Issue
Block a user