From 1688d6f538d98c8113ea14c9ab89535c011f5a01 Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 23 Oct 2025 20:14:51 +0200 Subject: [PATCH] feat: Enhance message handling for drawings - Added properties to the Message model to track drawing status and associated drawing ID. - Updated AppProvider to mark messages as drawings and link them to their respective drawing IDs. - Enhanced DrawingProvider to manage received drawings and their visibility. - Implemented navigation to drawings in MapProvider, including dynamic zoom levels based on drawing size. - Updated MessagesProvider to parse drawing messages and manage linked drawings. - Enhanced MessagesTab to support drawing message interactions, including navigation and coordinate copying. - Created DrawingMinimapPreview widget for visual representation of drawings in message bubbles. - Improved DrawingToolbar to handle sharing of drawings, ensuring only unshared drawings are sent. - Added utility functions in DrawingMessageParser for extracting drawing metadata. --- lib/l10n/app_de.arb | 15 +- lib/l10n/app_en.arb | 68 ++++++ lib/l10n/app_es.arb | 15 +- lib/l10n/app_fr.arb | 15 +- lib/l10n/app_hr.arb | 16 +- lib/l10n/app_it.arb | 15 +- lib/l10n/app_localizations.dart | 66 ++++++ lib/l10n/app_localizations_de.dart | 38 ++++ lib/l10n/app_localizations_en.dart | 43 ++++ lib/l10n/app_localizations_es.dart | 40 +++- lib/l10n/app_localizations_fr.dart | 40 +++- lib/l10n/app_localizations_hr.dart | 44 ++++ lib/l10n/app_localizations_it.dart | 39 +++- lib/l10n/app_localizations_sl.dart | 43 ++++ lib/l10n/app_sl.arb | 16 +- lib/models/map_drawing.dart | 91 +++++++- lib/models/message.dart | 26 +++ lib/providers/app_provider.dart | 10 +- lib/providers/drawing_provider.dart | 164 +++++++++++++- lib/providers/map_provider.dart | 50 +++++ lib/providers/messages_provider.dart | 58 ++++- lib/screens/messages_tab.dart | 272 ++++++++++++++++++++++- lib/utils/drawing_message_parser.dart | 109 +++++++++ lib/widgets/drawing_minimap_preview.dart | 135 +++++++++++ lib/widgets/map/drawing_toolbar.dart | 190 ++++++++-------- 25 files changed, 1504 insertions(+), 114 deletions(-) create mode 100644 lib/widgets/drawing_minimap_preview.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bca4043..e5967d7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2508,5 +2508,18 @@ "sampleEchoSupport": "Unterstützung", "sampleBaseCommand": "Basis-Kommando", "sampleFieldCoordinator": "Feldkoordinator", - "sampleMedicalTeam": "Medizinisches Team" + "sampleMedicalTeam": "Medizinisches Team", + + "mapDrawing": "Kartenzeichnung", + "drawingShared": "Kartenzeichnung", + "lineDrawing": "Linie", + "rectangleDrawing": "Rechteck", + "navigateToDrawing": "Zur Zeichnung navigieren", + "hideFromMap": "Von Karte ausblenden", + "copyCoordinates": "Koordinaten kopieren", + "coordinatesCopiedToClipboard": "Koordinaten in Zwischenablage kopiert", + "drawingHidden": "Zeichnung von Karte ausgeblendet", + "alreadyShared": "{count} bereits geteilt", + "newDrawingsShared": "{count} neue Zeichnung(en) geteilt", + "drawingTools": "Zeichenwerkzeuge" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8b62904..e8ced8a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2990,5 +2990,73 @@ "sampleMedicalTeam": "Medical Team", "@sampleMedicalTeam": { "description": "Sample team name" + }, + + "mapDrawing": "Map Drawing", + "@mapDrawing": { + "description": "Label for map drawing messages" + }, + + "navigateToDrawing": "Navigate to Drawing", + "@navigateToDrawing": { + "description": "Option to navigate to drawing on map" + }, + + "copyCoordinates": "Copy Coordinates", + "@copyCoordinates": { + "description": "Option to copy coordinates to clipboard" + }, + + "hideFromMap": "Hide from Map", + "@hideFromMap": { + "description": "Option to hide drawing from map" + }, + + "lineDrawing": "Line Drawing", + "@lineDrawing": { + "description": "Label for line type drawings" + }, + + "rectangleDrawing": "Rectangle Drawing", + "@rectangleDrawing": { + "description": "Label for rectangle type drawings" + }, + + "coordinatesCopiedToClipboard": "Coordinates copied to clipboard", + "@coordinatesCopiedToClipboard": { + "description": "Success message when coordinates are copied" + }, + + "drawingShared": "Map Drawing", + "@drawingShared": { + "description": "Label for shared drawing notifications" + }, + + "drawingHidden": "Drawing hidden from map", + "@drawingHidden": { + "description": "Success message when drawing is hidden" + }, + + "alreadyShared": "{count, plural, =1{1 already shared} other{{count} already shared}}", + "@alreadyShared": { + "description": "Message showing how many drawings were already shared", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "newDrawingsShared": "Shared {count} new drawing{plural}", + "@newDrawingsShared": { + "description": "Success message after sharing new drawings", + "placeholders": { + "count": { + "type": "int" + }, + "plural": { + "type": "String" + } + } } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index c4d7225..bda833e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2503,5 +2503,18 @@ "sampleEchoSupport": "Apoyo", "sampleBaseCommand": "Comando de Base", "sampleFieldCoordinator": "Coordinador de Campo", - "sampleMedicalTeam": "Equipo Médico" + "sampleMedicalTeam": "Equipo Médico", + + "mapDrawing": "Dibujo del Mapa", + "drawingShared": "Dibujo del Mapa", + "lineDrawing": "Línea", + "rectangleDrawing": "Rectángulo", + "navigateToDrawing": "Navegar al Dibujo", + "hideFromMap": "Ocultar del Mapa", + "copyCoordinates": "Copiar Coordenadas", + "coordinatesCopiedToClipboard": "Coordenadas copiadas al portapapeles", + "drawingHidden": "Dibujo ocultado del mapa", + "alreadyShared": "{count} ya compartido", + "newDrawingsShared": "{count} nuevo(s) dibujo(s) compartido(s)", + "drawingTools": "Herramientas de Dibujo" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 82337ce..051bc5c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2508,5 +2508,18 @@ "sampleEchoSupport": "Soutien", "sampleBaseCommand": "Commandement de Base", "sampleFieldCoordinator": "Coordinateur de Terrain", - "sampleMedicalTeam": "Équipe Médicale" + "sampleMedicalTeam": "Équipe Médicale", + + "mapDrawing": "Dessin de Carte", + "drawingShared": "Dessin de Carte", + "lineDrawing": "Ligne", + "rectangleDrawing": "Rectangle", + "navigateToDrawing": "Naviguer vers le Dessin", + "hideFromMap": "Masquer de la Carte", + "copyCoordinates": "Copier les Coordonnées", + "coordinatesCopiedToClipboard": "Coordonnées copiées dans le presse-papiers", + "drawingHidden": "Dessin masqué de la carte", + "alreadyShared": "{count} déjà partagé", + "newDrawingsShared": "{count} nouveau(x) dessin(s) partagé(s)", + "drawingTools": "Outils de Dessin" } diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb index c670035..549f6ec 100644 --- a/lib/l10n/app_hr.arb +++ b/lib/l10n/app_hr.arb @@ -918,5 +918,19 @@ "sampleEchoSupport": "Podrška", "sampleBaseCommand": "Zapovjedništvo Baze", "sampleFieldCoordinator": "Terenski Koordinator", - "sampleMedicalTeam": "Medicinski Tim" + "sampleMedicalTeam": "Medicinski Tim", + + "mapDrawing": "Crtež karte", + "navigateToDrawing": "Navigiraj do crteža", + "copyCoordinates": "Kopiraj koordinate", + "hideFromMap": "Sakrij s karte", + "lineDrawing": "Linijski crtež", + "rectangleDrawing": "Pravokutni crtež", + "coordinatesCopiedToClipboard": "Koordinate kopirane u međuspremnik", + + "drawingShared": "Crtež karte", + "drawingHidden": "Crtež sakriven s karte", + "alreadyShared": "{count, plural, =1{1 već podijeljeno} other{{count} već podijeljeno}}", + "newDrawingsShared": "Podijeljeno {count} novi{plural} crtež{plural}", + "drawingTools": "Alati za crtanje" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index f5154b0..bd5bb4a 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2508,5 +2508,18 @@ "sampleEchoSupport": "Supporto", "sampleBaseCommand": "Comando di Base", "sampleFieldCoordinator": "Coordinatore sul Campo", - "sampleMedicalTeam": "Team Medico" + "sampleMedicalTeam": "Team Medico", + + "mapDrawing": "Disegno della Mappa", + "drawingShared": "Disegno della Mappa", + "lineDrawing": "Linea", + "rectangleDrawing": "Rettangolo", + "navigateToDrawing": "Naviga al Disegno", + "hideFromMap": "Nascondi dalla Mappa", + "copyCoordinates": "Copia Coordinate", + "coordinatesCopiedToClipboard": "Coordinate copiate negli appunti", + "drawingHidden": "Disegno nascosto dalla mappa", + "alreadyShared": "{count} già condiviso", + "newDrawingsShared": "{count} nuovo(i) disegno(i) condiviso(i)", + "drawingTools": "Strumenti di Disegno" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f86c4c7..f3e73a7 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3188,6 +3188,72 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Medical Team'** String get sampleMedicalTeam; + + /// Label for map drawing messages + /// + /// In en, this message translates to: + /// **'Map Drawing'** + String get mapDrawing; + + /// Option to navigate to drawing on map + /// + /// In en, this message translates to: + /// **'Navigate to Drawing'** + String get navigateToDrawing; + + /// Option to copy coordinates to clipboard + /// + /// In en, this message translates to: + /// **'Copy Coordinates'** + String get copyCoordinates; + + /// Option to hide drawing from map + /// + /// In en, this message translates to: + /// **'Hide from Map'** + String get hideFromMap; + + /// Label for line type drawings + /// + /// In en, this message translates to: + /// **'Line Drawing'** + String get lineDrawing; + + /// Label for rectangle type drawings + /// + /// In en, this message translates to: + /// **'Rectangle Drawing'** + String get rectangleDrawing; + + /// Success message when coordinates are copied + /// + /// In en, this message translates to: + /// **'Coordinates copied to clipboard'** + String get coordinatesCopiedToClipboard; + + /// Label for shared drawing notifications + /// + /// In en, this message translates to: + /// **'Map Drawing'** + String get drawingShared; + + /// Success message when drawing is hidden + /// + /// In en, this message translates to: + /// **'Drawing hidden from map'** + String get drawingHidden; + + /// Message showing how many drawings were already shared + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 already shared} other{{count} already shared}}'** + String alreadyShared(int count); + + /// Success message after sharing new drawings + /// + /// In en, this message translates to: + /// **'Shared {count} new drawing{plural}'** + String newDrawingsShared(int count, String plural); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 0269dac..508912d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1782,4 +1782,42 @@ class AppLocalizationsDe extends AppLocalizations { @override String get sampleMedicalTeam => 'Medizinisches Team'; + + @override + String get mapDrawing => 'Kartenzeichnung'; + + @override + String get navigateToDrawing => 'Zur Zeichnung navigieren'; + + @override + String get copyCoordinates => 'Koordinaten kopieren'; + + @override + String get hideFromMap => 'Von Karte ausblenden'; + + @override + String get lineDrawing => 'Linie'; + + @override + String get rectangleDrawing => 'Rechteck'; + + @override + String get coordinatesCopiedToClipboard => + 'Koordinaten in Zwischenablage kopiert'; + + @override + String get drawingShared => 'Kartenzeichnung'; + + @override + String get drawingHidden => 'Zeichnung von Karte ausgeblendet'; + + @override + String alreadyShared(int count) { + return '$count bereits geteilt'; + } + + @override + String newDrawingsShared(int count, String plural) { + return '$count neue Zeichnung(en) geteilt'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 51c8ddc..ede5e65 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1758,4 +1758,47 @@ class AppLocalizationsEn extends AppLocalizations { @override String get sampleMedicalTeam => 'Medical Team'; + + @override + String get mapDrawing => 'Map Drawing'; + + @override + String get navigateToDrawing => 'Navigate to Drawing'; + + @override + String get copyCoordinates => 'Copy Coordinates'; + + @override + String get hideFromMap => 'Hide from Map'; + + @override + String get lineDrawing => 'Line Drawing'; + + @override + String get rectangleDrawing => 'Rectangle Drawing'; + + @override + String get coordinatesCopiedToClipboard => 'Coordinates copied to clipboard'; + + @override + String get drawingShared => 'Map Drawing'; + + @override + String get drawingHidden => 'Drawing hidden from map'; + + @override + String alreadyShared(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count already shared', + one: '1 already shared', + ); + return '$_temp0'; + } + + @override + String newDrawingsShared(int count, String plural) { + return 'Shared $count new drawing$plural'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 6327d89..d5af833 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -373,7 +373,7 @@ class AppLocalizationsEs extends AppLocalizations { String get deleteDrawing => 'Eliminar dibujo'; @override - String get drawingTools => 'Herramientas de dibujo'; + String get drawingTools => 'Herramientas de Dibujo'; @override String get drawLine => 'Dibujar línea'; @@ -1783,4 +1783,42 @@ class AppLocalizationsEs extends AppLocalizations { @override String get sampleMedicalTeam => 'Equipo Médico'; + + @override + String get mapDrawing => 'Dibujo del Mapa'; + + @override + String get navigateToDrawing => 'Navegar al Dibujo'; + + @override + String get copyCoordinates => 'Copiar Coordenadas'; + + @override + String get hideFromMap => 'Ocultar del Mapa'; + + @override + String get lineDrawing => 'Línea'; + + @override + String get rectangleDrawing => 'Rectángulo'; + + @override + String get coordinatesCopiedToClipboard => + 'Coordenadas copiadas al portapapeles'; + + @override + String get drawingShared => 'Dibujo del Mapa'; + + @override + String get drawingHidden => 'Dibujo ocultado del mapa'; + + @override + String alreadyShared(int count) { + return '$count ya compartido'; + } + + @override + String newDrawingsShared(int count, String plural) { + return '$count nuevo(s) dibujo(s) compartido(s)'; + } } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 95b3ce9..0262cd5 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -375,7 +375,7 @@ class AppLocalizationsFr extends AppLocalizations { String get deleteDrawing => 'Supprimer le dessin'; @override - String get drawingTools => 'Outils de dessin'; + String get drawingTools => 'Outils de Dessin'; @override String get drawLine => 'Tracer une ligne'; @@ -1789,4 +1789,42 @@ class AppLocalizationsFr extends AppLocalizations { @override String get sampleMedicalTeam => 'Équipe Médicale'; + + @override + String get mapDrawing => 'Dessin de Carte'; + + @override + String get navigateToDrawing => 'Naviguer vers le Dessin'; + + @override + String get copyCoordinates => 'Copier les Coordonnées'; + + @override + String get hideFromMap => 'Masquer de la Carte'; + + @override + String get lineDrawing => 'Ligne'; + + @override + String get rectangleDrawing => 'Rectangle'; + + @override + String get coordinatesCopiedToClipboard => + 'Coordonnées copiées dans le presse-papiers'; + + @override + String get drawingShared => 'Dessin de Carte'; + + @override + String get drawingHidden => 'Dessin masqué de la carte'; + + @override + String alreadyShared(int count) { + return '$count déjà partagé'; + } + + @override + String newDrawingsShared(int count, String plural) { + return '$count nouveau(x) dessin(s) partagé(s)'; + } } diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index a0a2e44..b5f6a65 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -1766,4 +1766,48 @@ class AppLocalizationsHr extends AppLocalizations { @override String get sampleMedicalTeam => 'Medicinski Tim'; + + @override + String get mapDrawing => 'Crtež karte'; + + @override + String get navigateToDrawing => 'Navigiraj do crteža'; + + @override + String get copyCoordinates => 'Kopiraj koordinate'; + + @override + String get hideFromMap => 'Sakrij s karte'; + + @override + String get lineDrawing => 'Linijski crtež'; + + @override + String get rectangleDrawing => 'Pravokutni crtež'; + + @override + String get coordinatesCopiedToClipboard => + 'Koordinate kopirane u međuspremnik'; + + @override + String get drawingShared => 'Crtež karte'; + + @override + String get drawingHidden => 'Crtež sakriven s karte'; + + @override + String alreadyShared(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count već podijeljeno', + one: '1 već podijeljeno', + ); + return '$_temp0'; + } + + @override + String newDrawingsShared(int count, String plural) { + return 'Podijeljeno $count novi$plural crtež$plural'; + } } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 0171707..e5baeae 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -374,7 +374,7 @@ class AppLocalizationsIt extends AppLocalizations { String get deleteDrawing => 'Elimina Disegno'; @override - String get drawingTools => 'Strumenti di disegno'; + String get drawingTools => 'Strumenti di Disegno'; @override String get drawLine => 'Disegna Linea'; @@ -1781,4 +1781,41 @@ class AppLocalizationsIt extends AppLocalizations { @override String get sampleMedicalTeam => 'Team Medico'; + + @override + String get mapDrawing => 'Disegno della Mappa'; + + @override + String get navigateToDrawing => 'Naviga al Disegno'; + + @override + String get copyCoordinates => 'Copia Coordinate'; + + @override + String get hideFromMap => 'Nascondi dalla Mappa'; + + @override + String get lineDrawing => 'Linea'; + + @override + String get rectangleDrawing => 'Rettangolo'; + + @override + String get coordinatesCopiedToClipboard => 'Coordinate copiate negli appunti'; + + @override + String get drawingShared => 'Disegno della Mappa'; + + @override + String get drawingHidden => 'Disegno nascosto dalla mappa'; + + @override + String alreadyShared(int count) { + return '$count già condiviso'; + } + + @override + String newDrawingsShared(int count, String plural) { + return '$count nuovo(i) disegno(i) condiviso(i)'; + } } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f65c324..dfa0cad 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1769,4 +1769,47 @@ class AppLocalizationsSl extends AppLocalizations { @override String get sampleMedicalTeam => 'Medicinska Ekipa'; + + @override + String get mapDrawing => 'Risba zemljevida'; + + @override + String get navigateToDrawing => 'Navigiraj do risbe'; + + @override + String get copyCoordinates => 'Kopiraj koordinate'; + + @override + String get hideFromMap => 'Skrij z zemljevida'; + + @override + String get lineDrawing => 'Linijska risba'; + + @override + String get rectangleDrawing => 'Pravokotna risba'; + + @override + String get coordinatesCopiedToClipboard => 'Koordinate kopirane v odložišče'; + + @override + String get drawingShared => 'Risba zemljevida'; + + @override + String get drawingHidden => 'Risba skrita z zemljevida'; + + @override + String alreadyShared(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count že deljeno', + one: '1 že deljeno', + ); + return '$_temp0'; + } + + @override + String newDrawingsShared(int count, String plural) { + return 'Deljeno $count nov$plural risb$plural'; + } } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 763c76e..54a82b3 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -918,5 +918,19 @@ "sampleEchoSupport": "Podpora", "sampleBaseCommand": "Poveljstvo Baze", "sampleFieldCoordinator": "Terenski Koordinator", - "sampleMedicalTeam": "Medicinska Ekipa" + "sampleMedicalTeam": "Medicinska Ekipa", + + "mapDrawing": "Risba zemljevida", + "navigateToDrawing": "Navigiraj do risbe", + "copyCoordinates": "Kopiraj koordinate", + "hideFromMap": "Skrij z zemljevida", + "lineDrawing": "Linijska risba", + "rectangleDrawing": "Pravokotna risba", + "coordinatesCopiedToClipboard": "Koordinate kopirane v odložišče", + + "drawingShared": "Risba zemljevida", + "drawingHidden": "Risba skrita z zemljevida", + "alreadyShared": "{count, plural, =1{1 že deljeno} other{{count} že deljeno}}", + "newDrawingsShared": "Deljeno {count} nov{plural} risb{plural}", + "drawingTools": "Orodja za risanje" } diff --git a/lib/models/map_drawing.dart b/lib/models/map_drawing.dart index 72e4d26..77deec7 100644 --- a/lib/models/map_drawing.dart +++ b/lib/models/map_drawing.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; /// Drawing shape type @@ -72,6 +73,9 @@ abstract class MapDrawing { final String? senderName; // Name of sender (null if local drawing) final bool isReceived; // True if drawing was received from another node final String? messageId; // ID of the source message (for navigation) + final bool isShared; // Whether drawing has been broadcast over mesh + final bool isSent; // Whether this is a sent drawing (vs received) + final bool isHidden; // Temporary visibility toggle (session only, not persisted) MapDrawing({ required this.id, @@ -81,6 +85,9 @@ abstract class MapDrawing { this.senderName, this.isReceived = false, this.messageId, + this.isShared = false, + this.isSent = false, + this.isHidden = false, }); /// Convert to JSON for persistence @@ -145,6 +152,12 @@ abstract class MapDrawing { return null; } } + + /// Get the center point of the drawing + LatLng getCenter(); + + /// Get the bounds of the drawing + LatLngBounds getBounds(); } /// Line drawing on map @@ -159,6 +172,9 @@ class LineDrawing extends MapDrawing { super.senderName, super.isReceived, super.messageId, + super.isShared, + super.isSent, + super.isHidden, }) : super(type: DrawingShapeType.line); @override @@ -169,6 +185,8 @@ class LineDrawing extends MapDrawing { 'color': color.toARGB32(), 'createdAt': createdAt.toIso8601String(), 'points': points.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(), + 'isShared': isShared, + // Note: isHidden is not persisted - it's session-only }; } @@ -176,11 +194,15 @@ class LineDrawing extends MapDrawing { Map toNetworkJson() { // Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points // Points are encoded as flat array [lat1,lon1,lat2,lon2,...] + // Coordinates rounded to 5 decimal places (~1m precision, like SAR markers) // Sender is fetched from packet metadata, not included in JSON return { 't': type.index, 'c': DrawingColors.colorToIndex(color), - 'p': points.expand((p) => [p.latitude, p.longitude]).toList(), + 'p': points.expand((p) => [ + double.parse(p.latitude.toStringAsFixed(5)), + double.parse(p.longitude.toStringAsFixed(5)), + ]).toList(), }; } @@ -196,6 +218,7 @@ class LineDrawing extends MapDrawing { points: points, senderName: senderName, isReceived: senderName != null, // Mark as received if sender is present + isShared: json['isShared'] as bool? ?? false, ); } @@ -219,6 +242,7 @@ class LineDrawing extends MapDrawing { senderName: senderName, isReceived: true, messageId: messageId, // Link to source message + isShared: false, // Received drawings are not marked as shared ); } @@ -231,6 +255,41 @@ class LineDrawing extends MapDrawing { points: points ?? this.points, ); } + + @override + LatLng getCenter() { + if (points.isEmpty) return LatLng(0, 0); + if (points.length == 1) return points[0]; + + // Calculate center as average of all points + double sumLat = 0; + double sumLon = 0; + for (final point in points) { + sumLat += point.latitude; + sumLon += point.longitude; + } + return LatLng(sumLat / points.length, sumLon / points.length); + } + + @override + LatLngBounds getBounds() { + if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0)); + if (points.length == 1) return LatLngBounds(points[0], points[0]); + + double minLat = points[0].latitude; + double maxLat = points[0].latitude; + double minLon = points[0].longitude; + double maxLon = points[0].longitude; + + for (final point in points) { + if (point.latitude < minLat) minLat = point.latitude; + if (point.latitude > maxLat) maxLat = point.latitude; + if (point.longitude < minLon) minLon = point.longitude; + if (point.longitude > maxLon) maxLon = point.longitude; + } + + return LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon)); + } } /// Rectangle drawing on map @@ -247,6 +306,9 @@ class RectangleDrawing extends MapDrawing { super.senderName, super.isReceived, super.messageId, + super.isShared, + super.isSent, + super.isHidden, }) : super(type: DrawingShapeType.rectangle); /// Get all corner points for rendering @@ -267,17 +329,25 @@ class RectangleDrawing extends MapDrawing { 'createdAt': createdAt.toIso8601String(), 'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude}, 'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude}, + 'isShared': isShared, + // Note: isHidden is not persisted - it's session-only }; } @override Map toNetworkJson() { // Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2] + // Coordinates rounded to 5 decimal places (~1m precision, like SAR markers) // Sender is fetched from packet metadata, not included in JSON return { 't': type.index, 'c': DrawingColors.colorToIndex(color), - 'b': [topLeft.latitude, topLeft.longitude, bottomRight.latitude, bottomRight.longitude], + 'b': [ + double.parse(topLeft.latitude.toStringAsFixed(5)), + double.parse(topLeft.longitude.toStringAsFixed(5)), + double.parse(bottomRight.latitude.toStringAsFixed(5)), + double.parse(bottomRight.longitude.toStringAsFixed(5)), + ], }; } @@ -294,6 +364,7 @@ class RectangleDrawing extends MapDrawing { bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double), senderName: senderName, isReceived: senderName != null, // Mark as received if sender is present + isShared: json['isShared'] as bool? ?? false, ); } @@ -314,6 +385,7 @@ class RectangleDrawing extends MapDrawing { senderName: senderName, isReceived: true, messageId: messageId, // Link to source message + isShared: false, // Received drawings are not marked as shared ); } @@ -330,4 +402,19 @@ class RectangleDrawing extends MapDrawing { bottomRight: bottomRight ?? this.bottomRight, ); } + + @override + LatLng getCenter() { + // Center is the midpoint between top-left and bottom-right + return LatLng( + (topLeft.latitude + bottomRight.latitude) / 2, + (topLeft.longitude + bottomRight.longitude) / 2, + ); + } + + @override + LatLngBounds getBounds() { + // Bounds are simply the two corners + return LatLngBounds(topLeft, bottomRight); + } } diff --git a/lib/models/message.dart b/lib/models/message.dart index 8cdc360..b7470ce 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -80,6 +80,10 @@ class Message { final int echoCount; // Number of times message was detected being rebroadcast final DateTime? firstEchoAt; // When first echo was detected + // Drawing message tracking + final bool isDrawing; // Whether this message contains a map drawing + final String? drawingId; // ID of the associated drawing (for navigation) + Message({ required this.id, required this.messageType, @@ -108,6 +112,8 @@ class Message { this.isRead = false, this.echoCount = 0, this.firstEchoAt, + this.isDrawing = false, + this.drawingId, }); /// Get SAR marker type by inferring from message content @@ -298,6 +304,22 @@ class Message { return false; } + /// Get drawing metadata from message text (returns null if not a drawing) + /// Extracts basic info for display in message bubbles + Map? get drawingMetadata { + if (!isDrawing || !text.startsWith('D:')) return null; + + try { + // Return basic metadata (actual parsing happens in DrawingMessageParser) + return { + 'hasDrawing': true, + 'drawingId': drawingId, + }; + } catch (e) { + return null; + } + } + Message copyWith({ String? id, MessageType? messageType, @@ -326,6 +348,8 @@ class Message { bool? isRead, int? echoCount, DateTime? firstEchoAt, + bool? isDrawing, + String? drawingId, }) { return Message( id: id ?? this.id, @@ -356,6 +380,8 @@ class Message { isRead: isRead ?? this.isRead, echoCount: echoCount ?? this.echoCount, firstEchoAt: firstEchoAt ?? this.firstEchoAt, + isDrawing: isDrawing ?? this.isDrawing, + drawingId: drawingId ?? this.drawingId, ); } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index ee54017..2fc5826 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -150,10 +150,16 @@ class AppProvider with ChangeNotifier { debugPrint(' Drawing linked to message ID: ${message.id}'); drawingProvider.addReceivedDrawing(drawing); - // Add the original drawing message to chat (not a modified info message) + // Update message to mark as drawing and link to drawing ID + final updatedMessage = message.copyWith( + isDrawing: true, + drawingId: drawing.id, + ); + + // Add the drawing message to chat with drawing metadata // This allows users to click on the drawing message to navigate to it messagesProvider.addMessage( - message, + updatedMessage, contactLookup: (name) => '', ); } else { diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index 3340488..236b3ed 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -31,9 +31,17 @@ class DrawingProvider with ChangeNotifier { Color get selectedColor => _selectedColor; bool get showReceivedDrawings => _showReceivedDrawings; bool get showSarMarkers => _showSarMarkers; - List get drawings => _showReceivedDrawings - ? List.unmodifiable(_drawings) - : List.unmodifiable(_drawings.where((d) => !d.isReceived).toList()); + List get drawings { + // Filter out hidden drawings first + var visibleDrawings = _drawings.where((d) => !d.isHidden); + + // Then filter by received status if needed + if (!_showReceivedDrawings) { + visibleDrawings = visibleDrawings.where((d) => !d.isReceived); + } + + return List.unmodifiable(visibleDrawings.toList()); + } MapDrawing? get currentDrawing => _currentDrawing; List get currentLinePoints => List.unmodifiable(_currentLinePoints); LatLng? get rectangleStartPoint => _rectangleStartPoint; @@ -281,7 +289,155 @@ class DrawingProvider with ChangeNotifier { return; } - _drawings.add(drawing); + // Mark as received when adding + final receivedDrawing = _createReceivedCopy(drawing); + _drawings.add(receivedDrawing); + _saveDrawings(); + notifyListeners(); + } + + /// Create a copy of a drawing marked as received + MapDrawing _createReceivedCopy(MapDrawing drawing) { + if (drawing is LineDrawing) { + return LineDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + points: drawing.points, + senderName: drawing.senderName, + isReceived: true, + messageId: drawing.messageId, + isShared: drawing.isShared, + isSent: drawing.isSent, + isHidden: drawing.isHidden, + ); + } else if (drawing is RectangleDrawing) { + return RectangleDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + topLeft: drawing.topLeft, + bottomRight: drawing.bottomRight, + senderName: drawing.senderName, + isReceived: true, + messageId: drawing.messageId, + isShared: drawing.isShared, + isSent: drawing.isSent, + isHidden: drawing.isHidden, + ); + } + return drawing; + } + + /// Get a drawing by its ID + MapDrawing? getDrawingById(String id) { + try { + return _drawings.firstWhere((d) => d.id == id); + } catch (e) { + return null; + } + } + + /// Get all unshared drawings (local drawings not yet sent) + List getUnsharedDrawings() { + return _drawings.where((d) => !d.isShared && !d.isReceived).toList(); + } + + /// Mark a drawing as shared + void markDrawingAsShared(String id) { + final index = _drawings.indexWhere((d) => d.id == id); + if (index != -1) { + final drawing = _drawings[index]; + + // Create a copy with isShared = true + if (drawing is LineDrawing) { + _drawings[index] = LineDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + points: drawing.points, + senderName: drawing.senderName, + isReceived: drawing.isReceived, + messageId: drawing.messageId, + isShared: true, + isSent: drawing.isSent, + isHidden: drawing.isHidden, + ); + } else if (drawing is RectangleDrawing) { + _drawings[index] = RectangleDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + topLeft: drawing.topLeft, + bottomRight: drawing.bottomRight, + senderName: drawing.senderName, + isReceived: drawing.isReceived, + messageId: drawing.messageId, + isShared: true, + isSent: drawing.isSent, + isHidden: drawing.isHidden, + ); + } + + _saveDrawings(); + notifyListeners(); + } + } + + /// Toggle visibility of a drawing (doesn't save to storage) + void toggleDrawingVisibility(String id) { + final index = _drawings.indexWhere((d) => d.id == id); + if (index != -1) { + final drawing = _drawings[index]; + + // Create a copy with toggled isHidden flag + if (drawing is LineDrawing) { + _drawings[index] = LineDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + points: drawing.points, + senderName: drawing.senderName, + isReceived: drawing.isReceived, + messageId: drawing.messageId, + isShared: drawing.isShared, + isSent: drawing.isSent, + isHidden: !drawing.isHidden, + ); + } else if (drawing is RectangleDrawing) { + _drawings[index] = RectangleDrawing( + id: drawing.id, + color: drawing.color, + createdAt: drawing.createdAt, + topLeft: drawing.topLeft, + bottomRight: drawing.bottomRight, + senderName: drawing.senderName, + isReceived: drawing.isReceived, + messageId: drawing.messageId, + isShared: drawing.isShared, + isSent: drawing.isSent, + isHidden: !drawing.isHidden, + ); + } + + // Don't save to storage - visibility toggle is temporary + notifyListeners(); + } + } + + /// Remove a drawing and its linked message + void removeDrawingAndMessage(String drawingId, dynamic messagesProvider) { + final drawing = getDrawingById(drawingId); + if (drawing == null) return; + + // Remove the drawing + _drawings.removeWhere((d) => d.id == drawingId); + + // If the drawing has a linked message, remove it too + if (drawing.messageId != null && messagesProvider != null) { + messagesProvider.deleteMessage(drawing.messageId!); + } + _saveDrawings(); notifyListeners(); } diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index e47a3a2..a0b9377 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -44,6 +44,56 @@ class MapProvider with ChangeNotifier { // Don't notify listeners to avoid rebuilds } + /// Navigate to a drawing by its ID + void navigateToDrawing(String drawingId, dynamic drawingProvider) { + // Find the drawing in the provider + final drawings = drawingProvider.drawings as List; + final drawing = drawings.cast().firstWhere( + (d) => d.id == drawingId, + orElse: () => null, + ); + + if (drawing == null) { + debugPrint('⚠️ [MapProvider] Drawing $drawingId not found'); + return; + } + + // Use MapDrawing's built-in getCenter and getBounds methods + final center = drawing.getCenter(); + final bounds = drawing.getBounds(); + + // Calculate appropriate zoom level based on bounds + // For larger drawings, use lower zoom to fit the whole drawing + // For smaller drawings, use higher zoom for better detail + final latDiff = (bounds.north - bounds.south).abs(); + final lonDiff = (bounds.east - bounds.west).abs(); + final maxDiff = latDiff > lonDiff ? latDiff : lonDiff; + + // Zoom scale: smaller drawings get higher zoom + // 0.001 degrees (~100m) -> zoom 17 + // 0.005 degrees (~500m) -> zoom 16 + // 0.01 degrees (~1km) -> zoom 15 + // 0.05 degrees (~5km) -> zoom 13 + // 0.1 degrees (~10km) -> zoom 12 + double zoom = 15.0; + if (maxDiff < 0.001) { + zoom = 17.0; + } else if (maxDiff < 0.005) { + zoom = 16.0; + } else if (maxDiff < 0.01) { + zoom = 15.0; + } else if (maxDiff < 0.05) { + zoom = 13.0; + } else if (maxDiff < 0.1) { + zoom = 12.0; + } else { + zoom = 10.0; + } + + debugPrint('🗺️ [MapProvider] Navigating to drawing: ${drawing.type.name}, zoom: $zoom'); + navigateToLocation(location: center, zoom: zoom, animate: true); + } + void updateZoom(double zoom) { _targetZoom = zoom; notifyListeners(); diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index f762c4d..b73291d 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -6,6 +6,7 @@ import '../models/sar_marker.dart'; import '../services/message_storage_service.dart'; import '../services/notification_service.dart'; import '../utils/sar_message_parser.dart'; +import '../utils/drawing_message_parser.dart'; import '../l10n/app_localizations.dart'; import 'helpers/message_retry_manager.dart'; @@ -139,7 +140,25 @@ class MessagesProvider with ChangeNotifier { String Function(String name)? contactLookup, }) { // Always enhance message with SAR parser to detect SAR markers - final enhancedMessage = SarMessageParser.enhanceMessage(message); + var enhancedMessage = SarMessageParser.enhanceMessage(message); + + // Check if it's a drawing message (D:...) and not already marked + // Don't overwrite if already set by the sender (preserves correct drawing ID) + if (DrawingMessageParser.isDrawingMessage(enhancedMessage.text) && + !enhancedMessage.isDrawing) { + // Parse the drawing to get its ID + final drawing = DrawingMessageParser.parseDrawingMessage( + enhancedMessage.text, + senderName: enhancedMessage.senderName, + messageId: enhancedMessage.id, + ); + + // Mark message as drawing and link to drawing ID + enhancedMessage = enhancedMessage.copyWith( + isDrawing: true, + drawingId: drawing?.id, + ); + } // For channel messages with sender name, try to link with contact Message finalMessage = enhancedMessage; @@ -497,6 +516,23 @@ class MessagesProvider with ChangeNotifier { } } + /// Delete a drawing message and its linked drawing + void deleteDrawingMessage(String messageId, dynamic drawingProvider) { + final message = _messages.firstWhere( + (m) => m.id == messageId, + orElse: () => throw Exception('Message not found'), + ); + + // If the message has a linked drawing, remove it + if (message.drawingId != null && drawingProvider != null) { + // Remove the drawing (DrawingProvider will handle removing this message) + drawingProvider.removeDrawing(message.drawingId!); + } else { + // No linked drawing, just delete the message + deleteMessage(messageId); + } + } + /// Clear all messages void clearMessages() { _messages.clear(); @@ -585,7 +621,25 @@ class MessagesProvider with ChangeNotifier { ); // Always enhance message with SAR parser to detect SAR markers - final enhancedMessage = SarMessageParser.enhanceMessage(message); + var enhancedMessage = SarMessageParser.enhanceMessage(message); + + // Check if it's a drawing message (D:...) and not already marked + // Don't overwrite if already set by the sender (preserves correct drawing ID) + if (DrawingMessageParser.isDrawingMessage(enhancedMessage.text) && + !enhancedMessage.isDrawing) { + // Parse the drawing to get its ID + final drawing = DrawingMessageParser.parseDrawingMessage( + enhancedMessage.text, + senderName: enhancedMessage.senderName, + messageId: enhancedMessage.id, + ); + + // Mark message as drawing and link to drawing ID + enhancedMessage = enhancedMessage.copyWith( + isDrawing: true, + drawingId: drawing?.id, + ); + } // Check for duplicates (shouldn't happen for sent messages, but be safe) if (_isDuplicate(enhancedMessage)) { diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 76eb9da..012d3ed 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -7,12 +7,15 @@ import '../providers/messages_provider.dart'; import '../providers/contacts_provider.dart'; import '../providers/map_provider.dart'; import '../providers/connection_provider.dart'; +import '../providers/drawing_provider.dart'; import '../models/message.dart'; import '../models/contact.dart'; import '../models/sar_marker.dart'; +import '../models/map_drawing.dart'; import '../widgets/messages/sar_update_sheet.dart'; import '../widgets/messages/recipient_selector_sheet.dart'; import '../widgets/contacts/direct_message_sheet.dart'; +import '../widgets/drawing_minimap_preview.dart'; import '../services/message_destination_preferences.dart'; import '../utils/toast_logger.dart'; import '../l10n/app_localizations.dart'; @@ -676,6 +679,7 @@ class _MessagesTabState extends State { return _MessageBubble( message: message, isHighlighted: isHighlighted, + onNavigateToMap: widget.onNavigateToMap, onTap: message.isSarMarker && message.sarGpsCoordinates != null @@ -688,6 +692,18 @@ class _MessagesTabState extends State { ); widget.onNavigateToMap(); } + : message.isDrawing && message.drawingId != null + ? () { + final mapProvider = context + .read(); + final drawingProvider = context + .read(); + mapProvider.navigateToDrawing( + message.drawingId!, + drawingProvider, + ); + widget.onNavigateToMap(); + } : null, ); }, @@ -809,11 +825,13 @@ class _MessageBubble extends StatelessWidget { final Message message; final VoidCallback? onTap; final bool isHighlighted; + final VoidCallback? onNavigateToMap; const _MessageBubble({ required this.message, this.onTap, this.isHighlighted = false, + this.onNavigateToMap, }); /// Helper method to compare two public keys for equality @@ -957,6 +975,36 @@ class _MessageBubble extends StatelessWidget { _shareLocation(context); }, ), + // Navigate to drawing option (only for drawing messages) + if (message.isDrawing && message.drawingId != null) + ListTile( + leading: const Icon(Icons.map), + title: Text(AppLocalizations.of(context)!.navigateToDrawing), + onTap: () { + Navigator.pop(context); + _navigateToDrawing(context); + }, + ), + // Copy coordinates option (only for drawing messages) + if (message.isDrawing && message.drawingId != null) + ListTile( + leading: const Icon(Icons.copy), + title: Text(AppLocalizations.of(context)!.copyCoordinates), + onTap: () { + Navigator.pop(context); + _copyDrawingCoordinates(context); + }, + ), + // Hide from map option (only for drawing messages) + if (message.isDrawing && message.drawingId != null) + ListTile( + leading: const Icon(Icons.visibility_off), + title: Text(AppLocalizations.of(context)!.hideFromMap), + onTap: () { + Navigator.pop(context); + _hideDrawingFromMap(context); + }, + ), // Delete message option ListTile( leading: const Icon(Icons.delete, color: Colors.red), @@ -1013,6 +1061,85 @@ class _MessageBubble extends StatelessWidget { ); } + void _navigateToDrawing(BuildContext context) { + if (message.drawingId == null) { + ToastLogger.error(context, 'No drawing ID available'); + return; + } + + final mapProvider = context.read(); + final drawingProvider = context.read(); + mapProvider.navigateToDrawing(message.drawingId!, drawingProvider); + onNavigateToMap?.call(); + } + + void _copyDrawingCoordinates(BuildContext context) { + if (message.drawingId == null) { + ToastLogger.error(context, 'No drawing ID available'); + return; + } + + final drawingProvider = context.read(); + final drawing = drawingProvider.drawings + .cast() + .firstWhere( + (d) => d?.id == message.drawingId, + orElse: () => null, + ); + + if (drawing == null) { + ToastLogger.error(context, 'Drawing not found'); + return; + } + + // Calculate center coordinates + String coordinates; + if (drawing is LineDrawing) { + // For line drawings, calculate center from all points + if (drawing.points.isEmpty) { + ToastLogger.error(context, 'No coordinates available'); + return; + } + double sumLat = 0; + double sumLon = 0; + for (final point in drawing.points) { + sumLat += point.latitude; + sumLon += point.longitude; + } + final centerLat = sumLat / drawing.points.length; + final centerLon = sumLon / drawing.points.length; + coordinates = '${centerLat.toStringAsFixed(5)}, ${centerLon.toStringAsFixed(5)}'; + } else if (drawing is RectangleDrawing) { + // For rectangle drawings, calculate center from bounds + final centerLat = (drawing.topLeft.latitude + drawing.bottomRight.latitude) / 2; + final centerLon = (drawing.topLeft.longitude + drawing.bottomRight.longitude) / 2; + coordinates = '${centerLat.toStringAsFixed(5)}, ${centerLon.toStringAsFixed(5)}'; + } else { + ToastLogger.error(context, 'Unknown drawing type'); + return; + } + + Clipboard.setData(ClipboardData(text: coordinates)); + ToastLogger.success( + context, + AppLocalizations.of(context)!.coordinatesCopiedToClipboard, + ); + } + + void _hideDrawingFromMap(BuildContext context) { + if (message.drawingId == null) { + ToastLogger.error(context, 'No drawing ID available'); + return; + } + + final drawingProvider = context.read(); + drawingProvider.removeDrawing(message.drawingId!); + ToastLogger.success( + context, + AppLocalizations.of(context)!.drawingHidden, + ); + } + void _showDeleteConfirmation(BuildContext context) { final l10n = AppLocalizations.of(context)!; showDialog( @@ -1029,6 +1156,13 @@ class _MessageBubble extends StatelessWidget { onPressed: () { final messagesProvider = context.read(); messagesProvider.deleteMessage(message.id); + + // Also delete the drawing if this is a drawing message + if (message.isDrawing && message.drawingId != null) { + final drawingProvider = context.read(); + drawingProvider.removeDrawing(message.drawingId!); + } + Navigator.pop(context); }, style: TextButton.styleFrom(foregroundColor: Colors.red), @@ -1210,6 +1344,10 @@ class _MessageBubble extends StatelessWidget { ? Theme.of(context).colorScheme.primaryContainer : isSarMarker ? _getSarMarkerColor(context, isDarkMode) + : message.isDrawing + ? (isDarkMode + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.15) + : Theme.of(context).colorScheme.primary.withValues(alpha: 0.08)) : _getMessageBubbleColor(context, isOwnMessage, isDarkMode), borderRadius: BorderRadius.circular(12), border: isHighlighted @@ -1222,6 +1360,11 @@ class _MessageBubble extends StatelessWidget { color: _getSarMarkerBorderColor(context, isDarkMode), width: 2, ) + : message.isDrawing + ? Border.all( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.4), + width: 2, + ) : isOwnMessage ? Border.all( color: Theme.of( @@ -1245,12 +1388,12 @@ class _MessageBubble extends StatelessWidget { offset: const Offset(0, 2), ), ] - : isSarMarker + : isSarMarker || message.isDrawing ? [ BoxShadow( - color: _getSarMarkerBorderColor( - context, - isDarkMode, + color: (isSarMarker + ? _getSarMarkerBorderColor(context, isDarkMode) + : Theme.of(context).colorScheme.primary ).withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 2), @@ -1309,6 +1452,37 @@ class _MessageBubble extends StatelessWidget { ], ), ) + else if (message.isDrawing) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.draw, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + AppLocalizations.of(context)!.mapDrawing, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ], + ), + ) else ...[ if (isOwnMessage) Icon( @@ -1409,6 +1583,96 @@ class _MessageBubble extends StatelessWidget { ], ), ] + // Drawing message content + else if (message.isDrawing && message.drawingId != null) + Consumer( + builder: (context, drawingProvider, child) { + // Find the drawing by ID + final drawing = drawingProvider.drawings + .cast() + .firstWhere( + (d) => d?.id == message.drawingId, + orElse: () => null, + ); + + if (drawing == null) { + return Text( + message.text, + style: Theme.of(context).textTheme.bodyMedium, + ); + } + + // Determine drawing type label + final String drawingTypeLabel; + if (drawing is LineDrawing) { + drawingTypeLabel = AppLocalizations.of(context)!.lineDrawing; + } else if (drawing is RectangleDrawing) { + drawingTypeLabel = AppLocalizations.of(context)!.rectangleDrawing; + } else { + drawingTypeLabel = AppLocalizations.of(context)!.drawing; + } + + // Get color name + final colorName = DrawingColors.colorToName(drawing.color); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Minimap preview + DrawingMinimapPreview(drawing: drawing), + const SizedBox(width: 12), + // Drawing info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + drawingTypeLabel, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: drawing.color, + shape: BoxShape.circle, + border: Border.all( + color: Colors.black26, + width: 1, + ), + ), + ), + const SizedBox(width: 6), + Text( + colorName, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + if (drawing.senderName != null) ...[ + const SizedBox(height: 4), + Text( + 'From: ${drawing.senderName}', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + ], + ], + ), + ), + Icon( + Icons.chevron_right, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ], + ); + }, + ) // Regular message content else Text(message.text, style: Theme.of(context).textTheme.bodyMedium), diff --git a/lib/utils/drawing_message_parser.dart b/lib/utils/drawing_message_parser.dart index 24944e8..0e9ed90 100644 --- a/lib/utils/drawing_message_parser.dart +++ b/lib/utils/drawing_message_parser.dart @@ -49,4 +49,113 @@ class DrawingMessageParser { final jsonStr = jsonEncode(json); return '$prefix$jsonStr'; } + + /// Get drawing type display name from drawing message text + /// Returns "Line" or "Rectangle", or null if parsing fails + static String? getDrawingTypeDisplay(String text) { + if (!isDrawingMessage(text)) return null; + + try { + final jsonStr = text.substring(prefix.length); + final json = jsonDecode(jsonStr) as Map; + final typeNum = json['t'] as int?; + + if (typeNum == null) return null; + + switch (typeNum) { + case 0: + return 'Line'; + case 1: + return 'Rectangle'; + default: + return null; + } + } catch (e) { + return null; + } + } + + /// Get color name from drawing message text + /// Returns color name like "Red", "Blue", etc., or null if parsing fails + static String? getColorName(String text) { + if (!isDrawingMessage(text)) return null; + + try { + final jsonStr = text.substring(prefix.length); + final json = jsonDecode(jsonStr) as Map; + final colorIndex = json['c'] as int?; + + if (colorIndex == null) return null; + + // Color mapping from DrawingColor enum + const colorNames = [ + 'Red', // 0 + 'Blue', // 1 + 'Green', // 2 + 'Yellow', // 3 + 'Orange', // 4 + 'Purple', // 5 + 'Pink', // 6 + 'Cyan', // 7 + ]; + + if (colorIndex >= 0 && colorIndex < colorNames.length) { + return colorNames[colorIndex]; + } + + return null; + } catch (e) { + return null; + } + } + + /// Get drawing metadata for display in message bubbles + /// Returns map with type, color, and pointCount, or null if parsing fails + static Map? getDrawingMetadata(String text) { + if (!isDrawingMessage(text)) return null; + + try { + final jsonStr = text.substring(prefix.length); + final json = jsonDecode(jsonStr) as Map; + + final typeNum = json['t'] as int?; + final colorIndex = json['c'] as int?; + + if (typeNum == null || colorIndex == null) return null; + + // Get type display name + String type; + int? pointCount; + + switch (typeNum) { + case 0: // Line + type = 'Line'; + final points = json['p'] as List?; + pointCount = points != null ? points.length ~/ 2 : null; + break; + case 1: // Rectangle + type = 'Rectangle'; + pointCount = 4; // Rectangles always have 4 corners + break; + default: + return null; + } + + // Get color name + const colorNames = [ + 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Purple', 'Pink', 'Cyan', + ]; + final color = colorIndex >= 0 && colorIndex < colorNames.length + ? colorNames[colorIndex] + : 'Unknown'; + + return { + 'type': type, + 'color': color, + 'pointCount': pointCount, + }; + } catch (e) { + return null; + } + } } diff --git a/lib/widgets/drawing_minimap_preview.dart b/lib/widgets/drawing_minimap_preview.dart new file mode 100644 index 0000000..e8f5b23 --- /dev/null +++ b/lib/widgets/drawing_minimap_preview.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import '../models/map_drawing.dart'; + +/// Minimap preview widget for map drawings +/// Renders a small 80x80px preview of a drawing on a simple gray background +class DrawingMinimapPreview extends StatelessWidget { + final MapDrawing drawing; + + const DrawingMinimapPreview({ + super.key, + required this.drawing, + }); + + /// Calculate bounds for the drawing to fit in the preview + LatLngBounds _calculateBounds() { + if (drawing is LineDrawing) { + final lineDrawing = drawing as LineDrawing; + if (lineDrawing.points.isEmpty) { + // Fallback to default bounds if no points + return LatLngBounds( + const LatLng(0, 0), + const LatLng(0.01, 0.01), + ); + } + + // Calculate bounds from points + double minLat = lineDrawing.points.first.latitude; + double maxLat = lineDrawing.points.first.latitude; + double minLon = lineDrawing.points.first.longitude; + double maxLon = lineDrawing.points.first.longitude; + + for (final point in lineDrawing.points) { + if (point.latitude < minLat) minLat = point.latitude; + if (point.latitude > maxLat) maxLat = point.latitude; + if (point.longitude < minLon) minLon = point.longitude; + if (point.longitude > maxLon) maxLon = point.longitude; + } + + // Add padding (10% on each side) + final latPadding = (maxLat - minLat) * 0.1; + final lonPadding = (maxLon - minLon) * 0.1; + + return LatLngBounds( + LatLng(minLat - latPadding, minLon - lonPadding), + LatLng(maxLat + latPadding, maxLon + lonPadding), + ); + } else if (drawing is RectangleDrawing) { + final rectDrawing = drawing as RectangleDrawing; + + // Add padding (10% on each side) + final latDiff = (rectDrawing.bottomRight.latitude - rectDrawing.topLeft.latitude).abs(); + final lonDiff = (rectDrawing.bottomRight.longitude - rectDrawing.topLeft.longitude).abs(); + final latPadding = latDiff * 0.1; + final lonPadding = lonDiff * 0.1; + + return LatLngBounds( + LatLng( + rectDrawing.topLeft.latitude - latPadding, + rectDrawing.topLeft.longitude - lonPadding, + ), + LatLng( + rectDrawing.bottomRight.latitude + latPadding, + rectDrawing.bottomRight.longitude + lonPadding, + ), + ); + } + + // Fallback to default bounds + return LatLngBounds( + const LatLng(0, 0), + const LatLng(0.01, 0.01), + ); + } + + @override + Widget build(BuildContext context) { + final bounds = _calculateBounds(); + + return Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.grey.shade400, + width: 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: FlutterMap( + options: MapOptions( + initialCameraFit: CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(8), + ), + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.none, // Disable all interactions + ), + ), + children: [ + // Simple gray background (no tiles for performance) + Container(color: Colors.grey.shade300), + + // Render the drawing + if (drawing is LineDrawing) + PolylineLayer( + polylines: [ + Polyline( + points: (drawing as LineDrawing).points, + strokeWidth: 3.0, + color: drawing.color, + ), + ], + ) + else if (drawing is RectangleDrawing) + PolygonLayer( + polygons: [ + Polygon( + points: (drawing as RectangleDrawing).corners, + color: drawing.color.withValues(alpha: 0.3), + borderColor: drawing.color, + borderStrokeWidth: 3.0, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/map/drawing_toolbar.dart b/lib/widgets/map/drawing_toolbar.dart index 407074a..577e437 100644 --- a/lib/widgets/map/drawing_toolbar.dart +++ b/lib/widgets/map/drawing_toolbar.dart @@ -6,6 +6,7 @@ import '../../providers/contacts_provider.dart'; import '../../providers/messages_provider.dart'; import '../../models/map_drawing.dart'; import '../../models/contact.dart'; +import '../../models/message.dart'; import '../../l10n/app_localizations.dart'; /// Toolbar for drawing controls on the map @@ -376,33 +377,19 @@ class DrawingToolbar extends StatelessWidget { ); if (!connectionProvider.deviceInfo.isConnected) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.deviceNotConnected), - backgroundColor: Colors.red, - ), - ); return; } // Get device name for sender identification final senderName = connectionProvider.deviceInfo.selfName ?? 'Unknown'; - // Filter drawings (only share local drawings, not received ones) - final localDrawings = drawingProvider.drawings - .where((d) => !d.isReceived) - .toList(); + // Filter drawings - only share unshared local drawings + final unsharedDrawings = drawingProvider.getUnsharedDrawings(); - debugPrint(' Local drawings count: ${localDrawings.length}'); + debugPrint(' Unshared drawings count: ${unsharedDrawings.length}'); debugPrint(' Total drawings count: ${drawingProvider.drawings.length}'); - if (localDrawings.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.noLocalDrawings), - backgroundColor: Colors.orange, - ), - ); + if (unsharedDrawings.isEmpty) { return; } @@ -429,8 +416,8 @@ class DrawingToolbar extends StatelessWidget { Expanded( child: Text( AppLocalizations.of(context)!.shareDrawingsCount( - localDrawings.length, - localDrawings.length > 1 ? 's' : '', + unsharedDrawings.length, + unsharedDrawings.length > 1 ? 's' : '', ), style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, @@ -451,7 +438,7 @@ class DrawingToolbar extends StatelessWidget { // Share BEFORE popping the navigator await _shareDrawingsToChannel( sheetContext, - localDrawings, + unsharedDrawings, connectionProvider, senderName, ); @@ -476,7 +463,7 @@ class DrawingToolbar extends StatelessWidget { // Share BEFORE popping the navigator await _shareDrawingsToRoom( sheetContext, - localDrawings, + unsharedDrawings, connectionProvider, senderName, room, @@ -516,9 +503,20 @@ class DrawingToolbar extends StatelessWidget { context, listen: false, ); + final messagesProvider = Provider.of( + context, + listen: false, + ); int successCount = 0; + int alreadyShared = 0; for (final drawing in drawings) { + // Skip if already shared + if (drawing.isShared) { + alreadyShared++; + continue; + } + try { debugPrint(' Creating message for drawing ${drawing.id}...'); // Sender name is no longer included in JSON - will be extracted from packet metadata @@ -526,13 +524,46 @@ class DrawingToolbar extends StatelessWidget { debugPrint( ' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...', ); + + // Create message ID and timestamp + final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_drawing_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object + final sentMessage = Message( + id: messageId, + messageType: MessageType.channel, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: message, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + channelIdx: 0, + isDrawing: true, + drawingId: drawing.id, + ); + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage); + debugPrint(' Sending to channel 0...'); await connectionProvider.sendChannelMessage( channelIdx: 0, text: message, + messageId: messageId, ); debugPrint(' ✅ Sent successfully'); + + // Mark as shared after successful send + drawingProvider.markDrawingAsShared(drawing.id); successCount++; + // Small delay between messages to avoid overwhelming the device await Future.delayed(const Duration(milliseconds: 200)); } catch (e, stackTrace) { @@ -541,36 +572,7 @@ class DrawingToolbar extends StatelessWidget { } } - debugPrint(' Share complete: $successCount/${drawings.length} sent'); - debugPrint(' Context mounted after send: ${context.mounted}'); - - if (!context.mounted) { - debugPrint('❌ Context not mounted, cannot show snackbar'); - return; - } - - // Add informational message to chat - final messagesProvider = Provider.of( - context, - listen: false, - ); - final l10n = AppLocalizations.of(context)!; - messagesProvider.logSystemMessage( - text: - '📤 ${l10n.drawingsSentToPublicChannel(drawings.length, drawings.length > 1 ? 's' : '')}', - level: 'info', - ); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - l10n.drawingsSharedToPublicChannel(successCount, drawings.length), - ), - backgroundColor: successCount == drawings.length - ? Colors.green - : Colors.orange, - ), - ); + debugPrint(' Share complete: $successCount/${drawings.length} sent, $alreadyShared already shared'); } /// Share drawings to a specific room @@ -596,9 +598,20 @@ class DrawingToolbar extends StatelessWidget { context, listen: false, ); + final messagesProvider = Provider.of( + context, + listen: false, + ); int successCount = 0; + int alreadyShared = 0; for (final drawing in drawings) { + // Skip if already shared + if (drawing.isShared) { + alreadyShared++; + continue; + } + try { debugPrint(' Creating message for drawing ${drawing.id}...'); // Sender name is no longer included in JSON - will be extracted from packet metadata @@ -606,13 +619,46 @@ class DrawingToolbar extends StatelessWidget { debugPrint( ' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...', ); + + // Create message ID and timestamp + final messageId = '${DateTime.now().millisecondsSinceEpoch}_contact_drawing_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object + final sentMessage = Message( + id: messageId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: message, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: room.publicKey, + isDrawing: true, + drawingId: drawing.id, + ); + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage); + debugPrint(' Sending to room ${room.advName}...'); await connectionProvider.sendTextMessage( contactPublicKey: room.publicKey, text: message, + messageId: messageId, ); debugPrint(' ✅ Sent successfully'); + + // Mark as shared after successful send + drawingProvider.markDrawingAsShared(drawing.id); successCount++; + // Small delay between messages to avoid overwhelming the device await Future.delayed(const Duration(milliseconds: 200)); } catch (e, stackTrace) { @@ -623,44 +669,6 @@ class DrawingToolbar extends StatelessWidget { } } - debugPrint(' Share complete: $successCount/${drawings.length} sent'); - debugPrint(' Context mounted after send: ${context.mounted}'); - - if (!context.mounted) { - debugPrint('❌ Context not mounted, cannot show snackbar'); - return; - } - - // Add informational message to chat - final messagesProvider = Provider.of( - context, - listen: false, - ); - final l10nRoom = AppLocalizations.of(context)!; - messagesProvider.logSystemMessage( - text: - '📤 ${l10nRoom.sentDrawingsToRoom( - drawings.length, - drawings.length > 1 ? 's' : '', - room.advName, - )}', - level: 'info', - ); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - l10nRoom.sharedDrawingsToRoom( - successCount, - drawings.length, - drawings.length > 1 ? 's' : '', - room.advName, - ), - ), - backgroundColor: successCount == drawings.length - ? Colors.green - : Colors.orange, - ), - ); + debugPrint(' Share complete: $successCount/${drawings.length} sent, $alreadyShared already shared'); } }