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.
This commit is contained in:
Janez T
2025-10-23 20:14:51 +02:00
parent f32667997e
commit 1688d6f538
25 changed files with 1504 additions and 114 deletions

View File

@@ -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"
}

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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';
}
}

View File

@@ -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';
}
}

View File

@@ -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)';
}
}

View File

@@ -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)';
}
}

View File

@@ -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';
}
}

View File

@@ -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)';
}
}

View File

@@ -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';
}
}

View File

@@ -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"
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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);
}
}

View File

@@ -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<String, dynamic>? 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,
);
}

View File

@@ -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 {

View File

@@ -31,9 +31,17 @@ class DrawingProvider with ChangeNotifier {
Color get selectedColor => _selectedColor;
bool get showReceivedDrawings => _showReceivedDrawings;
bool get showSarMarkers => _showSarMarkers;
List<MapDrawing> get drawings => _showReceivedDrawings
? List.unmodifiable(_drawings)
: List.unmodifiable(_drawings.where((d) => !d.isReceived).toList());
List<MapDrawing> 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<LatLng> 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<MapDrawing> 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();
}

View File

@@ -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<dynamic>().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();

View File

@@ -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)) {

View File

@@ -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<MessagesTab> {
return _MessageBubble(
message: message,
isHighlighted: isHighlighted,
onNavigateToMap: widget.onNavigateToMap,
onTap:
message.isSarMarker &&
message.sarGpsCoordinates != null
@@ -688,6 +692,18 @@ class _MessagesTabState extends State<MessagesTab> {
);
widget.onNavigateToMap();
}
: message.isDrawing && message.drawingId != null
? () {
final mapProvider = context
.read<MapProvider>();
final drawingProvider = context
.read<DrawingProvider>();
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<MapProvider>();
final drawingProvider = context.read<DrawingProvider>();
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<DrawingProvider>();
final drawing = drawingProvider.drawings
.cast<MapDrawing?>()
.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>();
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>();
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>();
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<DrawingProvider>(
builder: (context, drawingProvider, child) {
// Find the drawing by ID
final drawing = drawingProvider.drawings
.cast<MapDrawing?>()
.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),

View File

@@ -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<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>? getDrawingMetadata(String text) {
if (!isDrawingMessage(text)) return null;
try {
final jsonStr = text.substring(prefix.length);
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
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;
}
}
}

View File

@@ -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,
),
],
),
],
),
),
);
}
}

View File

@@ -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<MessagesProvider>(
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<MessagesProvider>(
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<MessagesProvider>(
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<MessagesProvider>(
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');
}
}