From fedd7b9a2b414ba19b84ed301d37132dc088de10 Mon Sep 17 00:00:00 2001 From: Janez T Date: Mon, 16 Mar 2026 10:50:44 +0100 Subject: [PATCH] Add custom cave map mode --- lib/models/custom_map_config.dart | 137 ++ lib/models/map_coordinate_space.dart | 11 + lib/models/map_drawing.dart | 291 ++- lib/models/message.dart | 15 +- lib/models/sar_marker.dart | 12 + lib/providers/drawing_provider.dart | 83 +- lib/providers/map_provider.dart | 468 ++++- lib/providers/messages_provider.dart | 2 + lib/screens/map_tab.dart | 1755 ++++++++++++----- lib/screens/messages_tab.dart | 28 +- lib/services/map_marker_service.dart | 3 +- lib/services/message_storage_service.dart | 16 +- lib/utils/custom_map_id.dart | 18 + lib/utils/drawing_message_parser.dart | 144 +- lib/utils/sar_message_parser.dart | 250 ++- lib/widgets/drawing_minimap_preview.dart | 32 +- lib/widgets/map/drawing_layer.dart | 32 +- .../messages/custom_map_sar_update_sheet.dart | 462 +++++ lib/widgets/messages/message_bubble.dart | 91 +- pubspec.lock | 8 +- pubspec.yaml | 2 + test/utils/drawing_message_parser_test.dart | 104 +- test/utils/sar_message_parser_test.dart | 42 +- 23 files changed, 2948 insertions(+), 1058 deletions(-) create mode 100644 lib/models/custom_map_config.dart create mode 100644 lib/models/map_coordinate_space.dart create mode 100644 lib/utils/custom_map_id.dart create mode 100644 lib/widgets/messages/custom_map_sar_update_sheet.dart diff --git a/lib/models/custom_map_config.dart b/lib/models/custom_map_config.dart new file mode 100644 index 0000000..5a479bd --- /dev/null +++ b/lib/models/custom_map_config.dart @@ -0,0 +1,137 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import '../utils/custom_map_id.dart'; + +class CustomMapConfig { + final String filePath; + final String displayName; + final String mapId; + final int imageWidth; + final int imageHeight; + final double? metersPerPixel; + final LatLng? calibrationPointA; + final LatLng? calibrationPointB; + + const CustomMapConfig({ + required this.filePath, + required this.displayName, + required this.mapId, + required this.imageWidth, + required this.imageHeight, + this.metersPerPixel, + this.calibrationPointA, + this.calibrationPointB, + }); + + bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0; + + LatLngBounds get bounds => LatLngBounds( + const LatLng(0, 0), + LatLng(imageHeight.toDouble(), imageWidth.toDouble()), + ); + + LatLngBounds get displayBounds => LatLngBounds( + LatLng(imageHeight.toDouble(), 0), + LatLng(0, imageWidth.toDouble()), + ); + + LatLng toDisplayPoint(LatLng point) { + return LatLng(imageHeight.toDouble() - point.latitude, point.longitude); + } + + LatLng fromDisplayPoint(LatLng point) { + return LatLng(imageHeight.toDouble() - point.latitude, point.longitude); + } + + Map toJson() { + return { + 'filePath': filePath, + 'displayName': displayName, + 'mapId': mapId, + 'imageWidth': imageWidth, + 'imageHeight': imageHeight, + 'metersPerPixel': metersPerPixel, + 'calibrationPointA': calibrationPointA != null + ? { + 'lat': calibrationPointA!.latitude, + 'lon': calibrationPointA!.longitude, + } + : null, + 'calibrationPointB': calibrationPointB != null + ? { + 'lat': calibrationPointB!.latitude, + 'lon': calibrationPointB!.longitude, + } + : null, + }; + } + + CustomMapConfig copyWith({ + String? filePath, + String? displayName, + String? mapId, + int? imageWidth, + int? imageHeight, + double? metersPerPixel, + bool clearMetersPerPixel = false, + LatLng? calibrationPointA, + bool clearCalibrationPointA = false, + LatLng? calibrationPointB, + bool clearCalibrationPointB = false, + }) { + return CustomMapConfig( + filePath: filePath ?? this.filePath, + displayName: displayName ?? this.displayName, + mapId: normalizeCustomMapId(mapId) ?? this.mapId, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + metersPerPixel: clearMetersPerPixel + ? null + : (metersPerPixel ?? this.metersPerPixel), + calibrationPointA: clearCalibrationPointA + ? null + : (calibrationPointA ?? this.calibrationPointA), + calibrationPointB: clearCalibrationPointB + ? null + : (calibrationPointB ?? this.calibrationPointB), + ); + } + + static CustomMapConfig? fromJson(Map? json) { + if (json == null) return null; + + final filePath = json['filePath']; + final displayName = json['displayName']; + final mapId = json['mapId']; + final imageWidth = json['imageWidth']; + final imageHeight = json['imageHeight']; + if (filePath is! String || + displayName is! String || + mapId is! String || + imageWidth is! int || + imageHeight is! int) { + return null; + } + + LatLng? parsePoint(dynamic raw) { + if (raw is! Map) return null; + final lat = raw['lat']; + final lon = raw['lon']; + if (lat is! num || lon is! num) return null; + return LatLng(lat.toDouble(), lon.toDouble()); + } + + final metersPerPixel = json['metersPerPixel']; + return CustomMapConfig( + filePath: filePath, + displayName: displayName, + mapId: normalizeCustomMapId(mapId) ?? mapId, + imageWidth: imageWidth, + imageHeight: imageHeight, + metersPerPixel: metersPerPixel is num ? metersPerPixel.toDouble() : null, + calibrationPointA: parsePoint(json['calibrationPointA']), + calibrationPointB: parsePoint(json['calibrationPointB']), + ); + } +} diff --git a/lib/models/map_coordinate_space.dart b/lib/models/map_coordinate_space.dart new file mode 100644 index 0000000..5ba1ad9 --- /dev/null +++ b/lib/models/map_coordinate_space.dart @@ -0,0 +1,11 @@ +enum MapCoordinateSpace { + geo, + customMap; + + static MapCoordinateSpace fromName(String? value) { + return MapCoordinateSpace.values.firstWhere( + (space) => space.name == value, + orElse: () => MapCoordinateSpace.geo, + ); + } +} diff --git a/lib/models/map_drawing.dart b/lib/models/map_drawing.dart index 4e329d0..419c88d 100644 --- a/lib/models/map_drawing.dart +++ b/lib/models/map_drawing.dart @@ -2,35 +2,26 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'map_coordinate_space.dart'; +import '../utils/custom_map_id.dart'; + /// Drawing shape type -enum DrawingShapeType { - line, - rectangle, -} +enum DrawingShapeType { line, rectangle } /// Drawing color enum for compact network transmission -enum DrawingColor { - red, // 0 - blue, // 1 - green, // 2 - yellow, // 3 - orange, // 4 - purple, // 5 - pink, // 6 - cyan, // 7 -} +enum DrawingColor { red, blue, green, yellow, orange, purple, pink, cyan } /// Drawing colors available for user selection class DrawingColors { static const List palette = [ - Colors.red, // index 0 - Colors.blue, // index 1 - Colors.green, // index 2 - Colors.yellow, // index 3 - Colors.orange, // index 4 - Colors.purple, // index 5 - Colors.pink, // index 6 - Colors.cyan, // index 7 + Colors.red, + Colors.blue, + Colors.green, + Colors.yellow, + Colors.orange, + Colors.purple, + Colors.pink, + Colors.cyan, ]; static String colorToName(Color color) { @@ -45,37 +36,36 @@ class DrawingColors { return 'Unknown'; } - /// Convert Color to enum index for network transmission static int colorToIndex(Color color) { for (int i = 0; i < palette.length; i++) { if (palette[i].toARGB32() == color.toARGB32()) { return i; } } - return 0; // Default to red if not found + return 0; } - /// Convert enum index to Color for network reception static Color indexToColor(int index) { if (index >= 0 && index < palette.length) { return palette[index]; } - return palette[0]; // Default to red if invalid index + return palette[0]; } } -/// Base class for map drawings abstract class MapDrawing { final String id; final DrawingShapeType type; final Color color; final DateTime createdAt; - 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) + final String? senderName; + final bool isReceived; + final String? messageId; + final bool isShared; + final bool isSent; + final bool isHidden; + final MapCoordinateSpace coordinateSpace; + final String? mapId; MapDrawing({ required this.id, @@ -88,79 +78,79 @@ abstract class MapDrawing { this.isShared = false, this.isSent = false, this.isHidden = false, + this.coordinateSpace = MapCoordinateSpace.geo, + this.mapId, }); - /// Convert to JSON for persistence + bool get isCustomMap => coordinateSpace == MapCoordinateSpace.customMap; + Map toJson(); - /// Convert to JSON for network transmission (compact format) - /// Uses short field names and excludes createdAt to minimize message size - /// Sender will be fetched from packet metadata Map toNetworkJson(); - /// Parse network JSON (compact format) - /// senderName and messageId will be populated from packet metadata static MapDrawing? fromNetworkJson( Map json, { String? senderName, String? messageId, + MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo, + String? mapId, }) { final typeNum = json['t'] as int?; - if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) { + if (typeNum == null || + typeNum < 0 || + typeNum >= DrawingShapeType.values.length) { return null; } try { final type = DrawingShapeType.values[typeNum]; - switch (type) { case DrawingShapeType.line: return LineDrawing.fromNetworkJson( json, senderName: senderName, messageId: messageId, + coordinateSpace: coordinateSpace, + mapId: mapId, ); case DrawingShapeType.rectangle: return RectangleDrawing.fromNetworkJson( json, senderName: senderName, messageId: messageId, + coordinateSpace: coordinateSpace, + mapId: mapId, ); } - } catch (e) { + } catch (_) { return null; } } - /// Create from JSON static MapDrawing? fromJson(Map json) { final typeStr = json['type'] as String?; if (typeStr == null) return null; try { final type = DrawingShapeType.values.firstWhere( - (e) => e.toString() == 'DrawingShapeType.$typeStr', + (value) => value.name == typeStr, ); - switch (type) { case DrawingShapeType.line: return LineDrawing.fromJson(json); case DrawingShapeType.rectangle: return RectangleDrawing.fromJson(json); } - } catch (e) { + } catch (_) { return null; } } - /// Get the center point of the drawing LatLng getCenter(); - /// Get the bounds of the drawing LatLngBounds getBounds(); } -/// Line drawing on map class LineDrawing extends MapDrawing { final List points; @@ -175,6 +165,8 @@ class LineDrawing extends MapDrawing { super.isShared, super.isSent, super.isHidden, + super.coordinateSpace, + super.mapId, }) : super(type: DrawingShapeType.line); @override @@ -184,31 +176,51 @@ class LineDrawing extends MapDrawing { 'type': type.name, 'color': color.toARGB32(), 'createdAt': createdAt.toIso8601String(), - 'points': points.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(), + 'points': points + .map((point) => {'lat': point.latitude, 'lon': point.longitude}) + .toList(), 'isShared': isShared, - // Note: isHidden is not persisted - it's session-only + 'coordinateSpace': coordinateSpace.name, + 'mapId': normalizeCustomMapId(mapId), }; } @override 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 { + final payload = { 't': type.index, 'c': DrawingColors.colorToIndex(color), - 'p': points.expand((p) => [ - double.parse(p.latitude.toStringAsFixed(5)), - double.parse(p.longitude.toStringAsFixed(5)), - ]).toList(), }; + + if (coordinateSpace == MapCoordinateSpace.customMap) { + payload['m'] = normalizeCustomMapId(mapId); + payload['p'] = points + .expand((point) => [point.latitude.round(), point.longitude.round()]) + .toList(); + return payload; + } + + payload['p'] = points + .expand( + (point) => [ + double.parse(point.latitude.toStringAsFixed(5)), + double.parse(point.longitude.toStringAsFixed(5)), + ], + ) + .toList(); + return payload; } static LineDrawing fromJson(Map json) { final pointsJson = json['points'] as List; - final points = pointsJson.map((p) => LatLng(p['lat'] as double, p['lon'] as double)).toList(); + final points = pointsJson + .map( + (point) => LatLng( + (point['lat'] as num).toDouble(), + (point['lon'] as num).toDouble(), + ), + ) + .toList(); final senderName = json['sender'] as String?; return LineDrawing( @@ -217,8 +229,12 @@ class LineDrawing extends MapDrawing { createdAt: DateTime.parse(json['createdAt'] as String), points: points, senderName: senderName, - isReceived: senderName != null, // Mark as received if sender is present + isReceived: senderName != null, isShared: json['isShared'] as bool? ?? false, + coordinateSpace: MapCoordinateSpace.fromName( + json['coordinateSpace'] as String?, + ), + mapId: normalizeCustomMapId(json['mapId'] as String?), ); } @@ -226,42 +242,60 @@ class LineDrawing extends MapDrawing { Map json, { String? senderName, String? messageId, + MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo, + String? mapId, }) { - // Parse ultra-compact format - final pointsFlat = (json['p'] as List).cast(); + final flatPoints = (json['p'] as List).cast(); final points = []; - for (int i = 0; i < pointsFlat.length; i += 2) { - points.add(LatLng(pointsFlat[i], pointsFlat[i + 1])); + for (int i = 0; i < flatPoints.length; i += 2) { + points.add( + LatLng(flatPoints[i].toDouble(), flatPoints[i + 1].toDouble()), + ); } return LineDrawing( - id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID + id: DateTime.now().millisecondsSinceEpoch.toString(), color: DrawingColors.indexToColor(json['c'] as int), createdAt: DateTime.now(), points: points, senderName: senderName, isReceived: true, - messageId: messageId, // Link to source message - isShared: false, // Received drawings are not marked as shared + messageId: messageId, + coordinateSpace: coordinateSpace, + mapId: normalizeCustomMapId(mapId), ); } - /// Create a copy with updated points - LineDrawing copyWith({List? points}) { + LineDrawing copyWith({ + List? points, + bool? isHidden, + bool? isShared, + bool? isReceived, + String? messageId, + String? senderName, + MapCoordinateSpace? coordinateSpace, + String? mapId, + }) { return LineDrawing( id: id, color: color, createdAt: createdAt, points: points ?? this.points, + isHidden: isHidden ?? this.isHidden, + isShared: isShared ?? this.isShared, + isReceived: isReceived ?? this.isReceived, + messageId: messageId ?? this.messageId, + senderName: senderName ?? this.senderName, + coordinateSpace: coordinateSpace ?? this.coordinateSpace, + mapId: mapId ?? this.mapId, ); } @override LatLng getCenter() { - if (points.isEmpty) return LatLng(0, 0); + if (points.isEmpty) return const 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) { @@ -273,8 +307,12 @@ class LineDrawing extends MapDrawing { @override LatLngBounds getBounds() { - if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0)); - if (points.length == 1) return LatLngBounds(points[0], points[0]); + if (points.isEmpty) { + return LatLngBounds(const LatLng(0, 0), const LatLng(0, 0)); + } + if (points.length == 1) { + return LatLngBounds(points[0], points[0]); + } double minLat = points[0].latitude; double maxLat = points[0].latitude; @@ -292,7 +330,6 @@ class LineDrawing extends MapDrawing { } } -/// Rectangle drawing on map class RectangleDrawing extends MapDrawing { final LatLng topLeft; final LatLng bottomRight; @@ -309,16 +346,17 @@ class RectangleDrawing extends MapDrawing { super.isShared, super.isSent, super.isHidden, + super.coordinateSpace, + super.mapId, }) : super(type: DrawingShapeType.rectangle); - /// Get all corner points for rendering List get corners => [ - topLeft, - LatLng(topLeft.latitude, bottomRight.longitude), // top right - bottomRight, - LatLng(bottomRight.latitude, topLeft.longitude), // bottom left - topLeft, // close the rectangle - ]; + topLeft, + LatLng(topLeft.latitude, bottomRight.longitude), + bottomRight, + LatLng(bottomRight.latitude, topLeft.longitude), + topLeft, + ]; @override Map toJson() { @@ -328,27 +366,41 @@ class RectangleDrawing extends MapDrawing { 'color': color.toARGB32(), 'createdAt': createdAt.toIso8601String(), 'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude}, - 'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude}, + 'bottomRight': { + 'lat': bottomRight.latitude, + 'lon': bottomRight.longitude, + }, 'isShared': isShared, - // Note: isHidden is not persisted - it's session-only + 'coordinateSpace': coordinateSpace.name, + 'mapId': normalizeCustomMapId(mapId), }; } @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 { + final payload = { 't': type.index, 'c': DrawingColors.colorToIndex(color), - '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)), - ], }; + + if (coordinateSpace == MapCoordinateSpace.customMap) { + payload['m'] = normalizeCustomMapId(mapId); + payload['b'] = [ + topLeft.latitude.round(), + topLeft.longitude.round(), + bottomRight.latitude.round(), + bottomRight.longitude.round(), + ]; + return payload; + } + + payload['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)), + ]; + return payload; } static RectangleDrawing fromJson(Map json) { @@ -360,11 +412,21 @@ class RectangleDrawing extends MapDrawing { id: json['id'] as String, color: Color(json['color'] as int), createdAt: DateTime.parse(json['createdAt'] as String), - topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double), - bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double), + topLeft: LatLng( + (topLeftJson['lat'] as num).toDouble(), + (topLeftJson['lon'] as num).toDouble(), + ), + bottomRight: LatLng( + (bottomRightJson['lat'] as num).toDouble(), + (bottomRightJson['lon'] as num).toDouble(), + ), senderName: senderName, - isReceived: senderName != null, // Mark as received if sender is present + isReceived: senderName != null, isShared: json['isShared'] as bool? ?? false, + coordinateSpace: MapCoordinateSpace.fromName( + json['coordinateSpace'] as String?, + ), + mapId: normalizeCustomMapId(json['mapId'] as String?), ); } @@ -372,27 +434,35 @@ class RectangleDrawing extends MapDrawing { Map json, { String? senderName, String? messageId, + MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo, + String? mapId, }) { - // Parse ultra-compact format - final bounds = (json['b'] as List).cast(); + final bounds = (json['b'] as List).cast(); return RectangleDrawing( - id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID + id: DateTime.now().millisecondsSinceEpoch.toString(), color: DrawingColors.indexToColor(json['c'] as int), createdAt: DateTime.now(), - topLeft: LatLng(bounds[0], bounds[1]), - bottomRight: LatLng(bounds[2], bounds[3]), + topLeft: LatLng(bounds[0].toDouble(), bounds[1].toDouble()), + bottomRight: LatLng(bounds[2].toDouble(), bounds[3].toDouble()), senderName: senderName, isReceived: true, - messageId: messageId, // Link to source message - isShared: false, // Received drawings are not marked as shared + messageId: messageId, + coordinateSpace: coordinateSpace, + mapId: normalizeCustomMapId(mapId), ); } - /// Create a copy with updated corners RectangleDrawing copyWith({ LatLng? topLeft, LatLng? bottomRight, + bool? isHidden, + bool? isShared, + bool? isReceived, + String? messageId, + String? senderName, + MapCoordinateSpace? coordinateSpace, + String? mapId, }) { return RectangleDrawing( id: id, @@ -400,12 +470,18 @@ class RectangleDrawing extends MapDrawing { createdAt: createdAt, topLeft: topLeft ?? this.topLeft, bottomRight: bottomRight ?? this.bottomRight, + isHidden: isHidden ?? this.isHidden, + isShared: isShared ?? this.isShared, + isReceived: isReceived ?? this.isReceived, + messageId: messageId ?? this.messageId, + senderName: senderName ?? this.senderName, + coordinateSpace: coordinateSpace ?? this.coordinateSpace, + mapId: mapId ?? this.mapId, ); } @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, @@ -414,7 +490,6 @@ class RectangleDrawing extends MapDrawing { @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 da271e9..012c2b9 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -9,6 +9,7 @@ export 'package:meshcore_client/meshcore_client.dart' import 'package:flutter/foundation.dart'; import 'package:meshcore_client/meshcore_client.dart'; import 'sar_marker.dart'; +import 'map_coordinate_space.dart'; import '../utils/voice_message_parser.dart'; extension MessageVoiceExtension on Message { @@ -47,7 +48,7 @@ extension MessageSarExtension on Message { /// Convert to a [SarMarker] if this message contains SAR data. SarMarker? toSarMarker() { - if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) { + if (!isSarMarker || sarMarkerType == null) { return null; } @@ -57,16 +58,26 @@ extension MessageSarExtension on Message { debugPrint(' message.sarMarkerType: $sarMarkerType'); debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"'); + final coordinateSpace = sarCustomMapPoint != null && sarCustomMapId != null + ? MapCoordinateSpace.customMap + : MapCoordinateSpace.geo; + final location = sarCustomMapPoint ?? sarGpsCoordinates; + if (location == null) { + return null; + } + return SarMarker( id: id, type: sarMarkerType!, - location: sarGpsCoordinates!, + location: location, timestamp: sentAt, senderPublicKey: senderPublicKeyPrefix, senderName: senderName, notes: sarNotes, customEmoji: sarCustomEmoji, colorIndex: sarColorIndex, + coordinateSpace: coordinateSpace, + mapId: sarCustomMapId, ); } } diff --git a/lib/models/sar_marker.dart b/lib/models/sar_marker.dart index a8f658f..9302ba7 100644 --- a/lib/models/sar_marker.dart +++ b/lib/models/sar_marker.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import '../l10n/app_localizations.dart'; +import 'map_coordinate_space.dart'; import '../services/sar_template_service.dart'; /// SAR (Search & Rescue) marker types @@ -78,6 +79,8 @@ class SarMarker { final String? notes; final String? customEmoji; // For custom SAR markers not in predefined types final int? colorIndex; // Color index (0-7) from standard palette + final MapCoordinateSpace coordinateSpace; + final String? mapId; SarMarker({ required this.id, @@ -89,8 +92,13 @@ class SarMarker { this.notes, this.customEmoji, this.colorIndex, + this.coordinateSpace = MapCoordinateSpace.geo, + this.mapId, }); + bool get isCustomMapMarker => + coordinateSpace == MapCoordinateSpace.customMap && mapId != null; + /// Get sender public key as hex string (short) String? get senderKeyShort { if (senderPublicKey == null || senderPublicKey!.length < 8) return null; @@ -167,6 +175,8 @@ class SarMarker { String? notes, String? customEmoji, int? colorIndex, + MapCoordinateSpace? coordinateSpace, + String? mapId, }) { return SarMarker( id: id ?? this.id, @@ -178,6 +188,8 @@ class SarMarker { notes: notes ?? this.notes, customEmoji: customEmoji ?? this.customEmoji, colorIndex: colorIndex ?? this.colorIndex, + coordinateSpace: coordinateSpace ?? this.coordinateSpace, + mapId: mapId ?? this.mapId, ); } diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index a986a7a..4a40917 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -1,8 +1,10 @@ import 'dart:convert'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/map_drawing.dart'; +import '../models/map_coordinate_space.dart'; import '../utils/drawing_message_parser.dart'; /// Drawing mode state @@ -33,12 +35,18 @@ class DrawingProvider with ChangeNotifier { LatLng? _measurementPoint1; LatLng? _measurementPoint2; double? _measuredDistance; // in meters + MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo; + String? _activeMapId; + double? _activeMetersPerPixel; // Getters DrawingMode get drawingMode => _drawingMode; Color get selectedColor => _selectedColor; bool get showReceivedDrawings => _showReceivedDrawings; bool get showSarMarkers => _showSarMarkers; + MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace; + String? get activeMapId => _activeMapId; + double? get activeMetersPerPixel => _activeMetersPerPixel; List get drawings { // Filter out hidden drawings first var visibleDrawings = _drawings.where((d) => !d.isHidden); @@ -48,8 +56,19 @@ class DrawingProvider with ChangeNotifier { visibleDrawings = visibleDrawings.where((d) => !d.isReceived); } + visibleDrawings = visibleDrawings.where((drawing) { + if (drawing.coordinateSpace != _activeCoordinateSpace) { + return false; + } + if (drawing.coordinateSpace == MapCoordinateSpace.customMap) { + return drawing.mapId != null && drawing.mapId == _activeMapId; + } + return true; + }); + return List.unmodifiable(visibleDrawings.toList()); } + MapDrawing? get currentDrawing => _currentDrawing; List get currentLinePoints => List.unmodifiable(_currentLinePoints); LatLng? get rectangleStartPoint => _rectangleStartPoint; @@ -66,6 +85,29 @@ class DrawingProvider with ChangeNotifier { _isInitialized = true; } + void setMapContext({ + required MapCoordinateSpace coordinateSpace, + String? mapId, + double? metersPerPixel, + }) { + final changed = + coordinateSpace != _activeCoordinateSpace || + mapId != _activeMapId || + metersPerPixel != _activeMetersPerPixel; + _activeCoordinateSpace = coordinateSpace; + _activeMapId = mapId; + _activeMetersPerPixel = metersPerPixel; + if (_measurementPoint1 != null && _measurementPoint2 != null) { + _measuredDistance = _calculateDistance( + _measurementPoint1!, + _measurementPoint2!, + ); + } + if (changed) { + notifyListeners(); + } + } + /// Set drawing mode void setDrawingMode(DrawingMode mode) { if (_drawingMode != mode) { @@ -137,6 +179,10 @@ class DrawingProvider with ChangeNotifier { color: _selectedColor, createdAt: DateTime.now(), points: List.from(_currentLinePoints), + coordinateSpace: _activeCoordinateSpace, + mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap + ? _activeMapId + : null, ); _drawings.add(drawing); @@ -180,6 +226,10 @@ class DrawingProvider with ChangeNotifier { ? _rectangleStartPoint!.longitude : endPoint.longitude, ), + coordinateSpace: _activeCoordinateSpace, + mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap + ? _activeMapId + : null, ); notifyListeners(); } @@ -216,6 +266,10 @@ class DrawingProvider with ChangeNotifier { createdAt: DateTime.now(), topLeft: topLeft, bottomRight: bottomRight, + coordinateSpace: _activeCoordinateSpace, + mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap + ? _activeMapId + : null, ); _drawings.add(drawing); @@ -237,7 +291,9 @@ class DrawingProvider with ChangeNotifier { /// Set second measurement point and calculate distance void setMeasurementPoint2(LatLng point) { - if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) return; + if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) { + return; + } _measurementPoint2 = point; _measuredDistance = _calculateDistance(_measurementPoint1!, point); @@ -246,6 +302,13 @@ class DrawingProvider with ChangeNotifier { /// Calculate distance between two points using Haversine formula double _calculateDistance(LatLng point1, LatLng point2) { + if (_activeCoordinateSpace == MapCoordinateSpace.customMap && + _activeMetersPerPixel != null) { + final dy = point2.latitude - point1.latitude; + final dx = point2.longitude - point1.longitude; + final pixelDistance = math.sqrt((dx * dx) + (dy * dy)); + return pixelDistance * _activeMetersPerPixel!; + } const Distance distance = Distance(); return distance.as(LengthUnit.Meter, point1, point2); } @@ -339,6 +402,10 @@ class DrawingProvider with ChangeNotifier { color: _selectedColor, createdAt: DateTime.now(), points: _currentLinePoints, + coordinateSpace: _activeCoordinateSpace, + mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap + ? _activeMapId + : null, ); } else if (_drawingMode == DrawingMode.rectangle && _currentDrawing != null) { @@ -376,6 +443,8 @@ class DrawingProvider with ChangeNotifier { isShared: drawing.isShared, isSent: drawing.isSent, isHidden: drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, ); } else if (drawing is RectangleDrawing) { return RectangleDrawing( @@ -390,6 +459,8 @@ class DrawingProvider with ChangeNotifier { isShared: drawing.isShared, isSent: drawing.isSent, isHidden: drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, ); } return drawing; @@ -406,7 +477,7 @@ class DrawingProvider with ChangeNotifier { /// Get all unshared drawings (local drawings not yet sent) List getUnsharedDrawings() { - return _drawings.where((d) => !d.isShared && !d.isReceived).toList(); + return drawings.where((d) => !d.isShared && !d.isReceived).toList(); } /// Mark a drawing as shared @@ -428,6 +499,8 @@ class DrawingProvider with ChangeNotifier { isShared: true, isSent: drawing.isSent, isHidden: drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, ); } else if (drawing is RectangleDrawing) { _drawings[index] = RectangleDrawing( @@ -442,6 +515,8 @@ class DrawingProvider with ChangeNotifier { isShared: true, isSent: drawing.isSent, isHidden: drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, ); } @@ -469,6 +544,8 @@ class DrawingProvider with ChangeNotifier { isShared: drawing.isShared, isSent: drawing.isSent, isHidden: !drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, ); } else if (drawing is RectangleDrawing) { _drawings[index] = RectangleDrawing( @@ -483,6 +560,8 @@ class DrawingProvider with ChangeNotifier { isShared: drawing.isShared, isSent: drawing.isSent, isHidden: !drawing.isHidden, + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, ); } diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index 2a4b3f9..e6c15ab 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -1,30 +1,47 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:latlong2/latlong.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/custom_map_config.dart'; import '../models/location_trail.dart'; +import '../models/map_coordinate_space.dart'; import '../models/map_drawing.dart'; +import '../models/sar_marker.dart'; +import '../utils/custom_map_id.dart'; class MapProvider with ChangeNotifier { + static const String _customMapConfigKey = 'custom_map_config_v1'; + static const String _customMapModeKey = 'custom_map_mode_v1'; + static const String _customMapsDirName = 'custom_maps'; + MapProvider() { unawaited(_loadInitialState()); } + final ImagePicker _imagePicker = ImagePicker(); + LatLng? _targetLocation; + LatLngBounds? _targetBounds; double? _targetZoom; bool _shouldAnimate = false; + MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo; + String? _targetMapId; - // Track which contact paths are currently visible final Set _visibleContactPaths = {}; - // Location trail tracking LocationTrail? _currentTrail; bool _isTrailVisible = true; final List _trailHistory = []; - // WMS overlay toggles bool _showCadastralOverlay = false; bool _showForestRoadsOverlay = false; bool _showHikingTrailsOverlay = false; @@ -37,29 +54,30 @@ class MapProvider with ChangeNotifier { bool _showPlaceNamesOverlay = false; bool _showMunicipalityBordersOverlay = false; - // Contact trail toggles - bool _showAllContactTrails = true; // Default to showing all contact trails + bool _showAllContactTrails = true; bool _hideRepeatersOnMap = false; - // Imported trail (from GPX) LocationTrail? _importedTrail; - // Download area selection bool _isSelectingDownloadArea = false; LatLngBounds? _downloadAreaBounds; + CustomMapConfig? _customMapConfig; + bool _isUsingCustomMap = false; + LatLng? get targetLocation => _targetLocation; + LatLngBounds? get targetBounds => _targetBounds; double? get targetZoom => _targetZoom; bool get shouldAnimate => _shouldAnimate; + MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace; + String? get targetMapId => _targetMapId; Set get visibleContactPaths => Set.unmodifiable(_visibleContactPaths); - // Trail getters LocationTrail? get currentTrail => _currentTrail; bool get isTrailVisible => _isTrailVisible; List get trailHistory => List.unmodifiable(_trailHistory); bool get isTrailActive => _currentTrail?.isActive ?? false; - // WMS overlay getters bool get showCadastralOverlay => _showCadastralOverlay; bool get showForestRoadsOverlay => _showForestRoadsOverlay; bool get showHikingTrailsOverlay => _showHikingTrailsOverlay; @@ -72,69 +90,135 @@ class MapProvider with ChangeNotifier { bool get showPlaceNamesOverlay => _showPlaceNamesOverlay; bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay; - // Contact trail getters bool get showAllContactTrails => _showAllContactTrails; bool get hideRepeatersOnMap => _hideRepeatersOnMap; - // Imported trail getters LocationTrail? get importedTrail => _importedTrail; - // Download area getters bool get isSelectingDownloadArea => _isSelectingDownloadArea; LatLngBounds? get downloadAreaBounds => _downloadAreaBounds; + CustomMapConfig? get customMapConfig => _customMapConfig; + bool get hasCustomMap => _customMapConfig != null; + bool get isUsingCustomMap => _isUsingCustomMap && _customMapConfig != null; + bool get shouldHideGpsData => isUsingCustomMap; + + LatLngBounds? get customMapBounds => _customMapConfig?.bounds; + + bool matchesActiveCustomMap(String? mapId) { + return hasCustomMap && + normalizeCustomMapId(_customMapConfig!.mapId) == + normalizeCustomMapId(mapId); + } + void navigateToLocation({ required LatLng location, double zoom = 15.0, bool animate = true, }) { + if (_isUsingCustomMap) { + _isUsingCustomMap = false; + unawaited(_saveCustomMapState()); + } _targetLocation = location; + _targetBounds = null; _targetZoom = zoom; _shouldAnimate = animate; + _targetCoordinateSpace = MapCoordinateSpace.geo; + _targetMapId = null; notifyListeners(); } + String? navigateToMapPoint({ + required LatLng point, + required MapCoordinateSpace coordinateSpace, + String? mapId, + double zoom = 15.0, + bool animate = true, + }) { + if (coordinateSpace == MapCoordinateSpace.customMap) { + if (!matchesActiveCustomMap(mapId)) { + return 'Load the matching custom map to view this item.'; + } + if (!_isUsingCustomMap) { + _isUsingCustomMap = true; + unawaited(_saveCustomMapState()); + } + } else if (_isUsingCustomMap) { + _isUsingCustomMap = false; + unawaited(_saveCustomMapState()); + } + + _targetLocation = point; + _targetBounds = null; + _targetZoom = zoom; + _shouldAnimate = animate; + _targetCoordinateSpace = coordinateSpace; + _targetMapId = mapId; + notifyListeners(); + return null; + } + + String? navigateToBounds({ + required LatLngBounds bounds, + required MapCoordinateSpace coordinateSpace, + String? mapId, + bool animate = true, + }) { + if (coordinateSpace == MapCoordinateSpace.customMap) { + if (!matchesActiveCustomMap(mapId)) { + return 'Load the matching custom map to view this item.'; + } + if (!_isUsingCustomMap) { + _isUsingCustomMap = true; + unawaited(_saveCustomMapState()); + } + } else if (_isUsingCustomMap) { + _isUsingCustomMap = false; + unawaited(_saveCustomMapState()); + } + + _targetBounds = bounds; + _targetLocation = bounds.center; + _targetZoom = null; + _shouldAnimate = animate; + _targetCoordinateSpace = coordinateSpace; + _targetMapId = mapId; + notifyListeners(); + return null; + } + void clearNavigation() { _targetLocation = null; + _targetBounds = null; _targetZoom = null; _shouldAnimate = false; - // Don't notify listeners to avoid rebuilds + _targetCoordinateSpace = MapCoordinateSpace.geo; + _targetMapId = null; } - /// Navigate to a drawing by its ID - void navigateToDrawing(String drawingId, dynamic drawingProvider) { - debugPrint('πŸ—ΊοΈ [MapProvider] navigateToDrawing called with ID: $drawingId'); - // Find the drawing in the provider - final drawings = drawingProvider.drawings as List; - debugPrint('πŸ—ΊοΈ [MapProvider] Total drawings in provider: ${drawings.length}'); - final drawing = drawings.cast().firstWhere( - (d) => d.id == drawingId, - orElse: () => null, - ); - + String? navigateToDrawing(String drawingId, dynamic drawingProvider) { + final drawing = drawingProvider.getDrawingById(drawingId) as MapDrawing?; if (drawing == null) { - debugPrint('⚠️ [MapProvider] Drawing $drawingId not found'); - debugPrint('⚠️ [MapProvider] Available drawing IDs: ${drawings.map((d) => d.id).toList()}'); - return; + return 'Drawing not found.'; } - // Use MapDrawing's built-in getCenter and getBounds methods - final center = drawing.getCenter(); - final bounds = drawing.getBounds(); + if (drawing.coordinateSpace == MapCoordinateSpace.customMap) { + if (!matchesActiveCustomMap(drawing.mapId)) { + return 'Load the matching custom map to view this drawing.'; + } + return navigateToBounds( + bounds: drawing.getBounds(), + coordinateSpace: drawing.coordinateSpace, + mapId: drawing.mapId, + ); + } - // 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 bounds = drawing.getBounds(); 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; @@ -150,9 +234,125 @@ class MapProvider with ChangeNotifier { zoom = 10.0; } - final typeStr = drawing is LineDrawing ? 'line' : 'rectangle'; - debugPrint('πŸ—ΊοΈ [MapProvider] Navigating to drawing: $typeStr, zoom: $zoom'); - navigateToLocation(location: center, zoom: zoom, animate: true); + navigateToLocation( + location: drawing.getCenter(), + zoom: zoom, + animate: true, + ); + return null; + } + + String? navigateToSarMarker(SarMarker marker) { + if (marker.coordinateSpace == MapCoordinateSpace.customMap) { + return navigateToMapPoint( + point: marker.location, + coordinateSpace: MapCoordinateSpace.customMap, + mapId: marker.mapId, + ); + } + navigateToLocation(location: marker.location, zoom: 15.0, animate: true); + return null; + } + + Future loadCustomMapFromGallery() async { + final picked = await _imagePicker.pickImage(source: ImageSource.gallery); + if (picked == null) { + return false; + } + final bytes = await picked.readAsBytes(); + await setCustomMapImage(bytes: bytes, displayName: picked.name); + return true; + } + + Future replaceCustomMap() async { + await loadCustomMapFromGallery(); + } + + Future setCustomMapImage({ + required Uint8List bytes, + required String displayName, + }) async { + final dimensions = await _decodeImageSize(bytes); + final mapId = normalizeCustomMapId(sha256.convert(bytes).toString())!; + final documentsDir = await getApplicationDocumentsDirectory(); + final dir = Directory('${documentsDir.path}/$_customMapsDirName'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + final extension = _normalizedExtension(displayName); + final nextPath = '${dir.path}/custom_map_$mapId.$extension'; + final nextFile = File(nextPath); + await nextFile.writeAsBytes(bytes, flush: true); + + final previousPath = _customMapConfig?.filePath; + _customMapConfig = CustomMapConfig( + filePath: nextPath, + displayName: displayName, + mapId: mapId, + imageWidth: dimensions.$1, + imageHeight: dimensions.$2, + ); + _isUsingCustomMap = true; + await _saveCustomMapState(); + + if (previousPath != null && previousPath != nextPath) { + unawaited(_deleteFileIfExists(previousPath)); + } + + notifyListeners(); + } + + Future setCustomMapCalibration({ + required LatLng pointA, + required LatLng pointB, + required double metersPerPixel, + }) async { + if (_customMapConfig == null) return; + _customMapConfig = _customMapConfig!.copyWith( + calibrationPointA: pointA, + calibrationPointB: pointB, + metersPerPixel: metersPerPixel, + ); + await _saveCustomMapState(); + notifyListeners(); + } + + Future clearCustomMapCalibration() async { + if (_customMapConfig == null) return; + _customMapConfig = _customMapConfig!.copyWith( + clearMetersPerPixel: true, + clearCalibrationPointA: true, + clearCalibrationPointB: true, + ); + await _saveCustomMapState(); + notifyListeners(); + } + + Future removeCustomMap() async { + final filePath = _customMapConfig?.filePath; + _customMapConfig = null; + _isUsingCustomMap = false; + clearNavigation(); + await _saveCustomMapState(); + if (filePath != null) { + await _deleteFileIfExists(filePath); + } + notifyListeners(); + } + + Future enterCustomMapMode() async { + if (!hasCustomMap || _isUsingCustomMap) return; + _isUsingCustomMap = true; + await _saveCustomMapState(); + notifyListeners(); + } + + Future exitCustomMapMode() async { + if (!_isUsingCustomMap) return; + _isUsingCustomMap = false; + await _saveCustomMapState(); + notifyListeners(); } void updateZoom(double zoom) { @@ -160,7 +360,6 @@ class MapProvider with ChangeNotifier { notifyListeners(); } - /// Toggle path visibility for a contact void toggleContactPath(String publicKeyHex) { if (_visibleContactPaths.contains(publicKeyHex)) { _visibleContactPaths.remove(publicKeyHex); @@ -170,27 +369,22 @@ class MapProvider with ChangeNotifier { notifyListeners(); } - /// Check if a contact's path is visible bool isContactPathVisible(String publicKeyHex) { return _visibleContactPaths.contains(publicKeyHex); } - /// Hide all contact paths void hideAllPaths() { _visibleContactPaths.clear(); notifyListeners(); } - /// Show path for specific contact (hide all others) void showOnlyPath(String publicKeyHex) { _visibleContactPaths.clear(); _visibleContactPaths.add(publicKeyHex); notifyListeners(); } - /// Start a new location trail void startTrail() { - // End current trail if active if (_currentTrail != null && _currentTrail!.isActive) { endTrail(); } @@ -203,22 +397,22 @@ class MapProvider with ChangeNotifier { notifyListeners(); } - /// Add a point to the current trail void addTrailPoint(LatLng position, {double? accuracy, double? speed}) { if (_currentTrail == null || !_currentTrail!.isActive) { startTrail(); } - _currentTrail!.addPoint(TrailPoint( - position: position, - timestamp: DateTime.now(), - accuracy: accuracy, - speed: speed, - )); + _currentTrail!.addPoint( + TrailPoint( + position: position, + timestamp: DateTime.now(), + accuracy: accuracy, + speed: speed, + ), + ); notifyListeners(); } - /// End the current trail void endTrail() { if (_currentTrail != null) { _currentTrail!.isActive = false; @@ -231,13 +425,11 @@ class MapProvider with ChangeNotifier { } } - /// Toggle trail visibility void toggleTrailVisibility() { _isTrailVisible = !_isTrailVisible; notifyListeners(); } - /// Clear the current trail void clearCurrentTrail() { if (_currentTrail != null) { _currentTrail = null; @@ -245,155 +437,173 @@ class MapProvider with ChangeNotifier { } } - /// Clear all trail history void clearAllTrails() { _currentTrail = null; _trailHistory.clear(); notifyListeners(); } - /// Get total trail distance in meters double get totalTrailDistance { if (_currentTrail == null) return 0; return _currentTrail!.totalDistance; } - /// Get trail duration Duration get trailDuration { if (_currentTrail == null) return Duration.zero; return _currentTrail!.duration; } - /// Toggle cadastral parcels overlay Future toggleCadastralOverlay() async { _showCadastralOverlay = !_showCadastralOverlay; notifyListeners(); await _saveOverlayState(); } - /// Toggle forest roads overlay Future toggleForestRoadsOverlay() async { _showForestRoadsOverlay = !_showForestRoadsOverlay; notifyListeners(); await _saveOverlayState(); } - /// Toggle hiking trails overlay Future toggleHikingTrailsOverlay() async { _showHikingTrailsOverlay = !_showHikingTrailsOverlay; notifyListeners(); await _saveOverlayState(); } - /// Toggle main roads overlay Future toggleMainRoadsOverlay() async { _showMainRoadsOverlay = !_showMainRoadsOverlay; notifyListeners(); await _saveOverlayState(); } - /// Toggle house numbers overlay Future toggleHouseNumbersOverlay() async { _showHouseNumbersOverlay = !_showHouseNumbersOverlay; notifyListeners(); await _saveOverlayState(); } - /// Toggle fire hazard zones overlay Future toggleFireHazardZonesOverlay() async { _showFireHazardZonesOverlay = !_showFireHazardZonesOverlay; notifyListeners(); await _saveOverlayState(); } - /// Toggle historical fires overlay Future toggleHistoricalFiresOverlay() async { _showHistoricalFiresOverlay = !_showHistoricalFiresOverlay; notifyListeners(); await _saveOverlayState(); } - /// Toggle firebreaks overlay Future toggleFirebreaksOverlay() async { _showFirebreaksOverlay = !_showFirebreaksOverlay; notifyListeners(); await _saveOverlayState(); } - /// Toggle Kras fire zones overlay Future toggleKrasFireZonesOverlay() async { _showKrasFireZonesOverlay = !_showKrasFireZonesOverlay; notifyListeners(); await _saveOverlayState(); } - /// Toggle place names overlay Future togglePlaceNamesOverlay() async { _showPlaceNamesOverlay = !_showPlaceNamesOverlay; notifyListeners(); await _saveOverlayState(); } - /// Toggle municipality borders overlay Future toggleMunicipalityBordersOverlay() async { _showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay; notifyListeners(); await _saveOverlayState(); } - /// Load overlay state from SharedPreferences Future loadOverlayState() async { final prefs = await SharedPreferences.getInstance(); - _showCadastralOverlay = prefs.getBool('map_show_cadastral_overlay') ?? false; - _showForestRoadsOverlay = prefs.getBool('map_show_forest_roads_overlay') ?? false; - _showHikingTrailsOverlay = prefs.getBool('map_show_hiking_trails_overlay') ?? false; - _showMainRoadsOverlay = prefs.getBool('map_show_main_roads_overlay') ?? false; - _showHouseNumbersOverlay = prefs.getBool('map_show_house_numbers_overlay') ?? false; - _showFireHazardZonesOverlay = prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false; - _showHistoricalFiresOverlay = prefs.getBool('map_show_historical_fires_overlay') ?? false; - _showFirebreaksOverlay = prefs.getBool('map_show_firebreaks_overlay') ?? false; - _showKrasFireZonesOverlay = prefs.getBool('map_show_kras_fire_zones_overlay') ?? false; - _showPlaceNamesOverlay = prefs.getBool('map_show_place_names_overlay') ?? false; - _showMunicipalityBordersOverlay = prefs.getBool('map_show_municipality_borders_overlay') ?? false; + _showCadastralOverlay = + prefs.getBool('map_show_cadastral_overlay') ?? false; + _showForestRoadsOverlay = + prefs.getBool('map_show_forest_roads_overlay') ?? false; + _showHikingTrailsOverlay = + prefs.getBool('map_show_hiking_trails_overlay') ?? false; + _showMainRoadsOverlay = + prefs.getBool('map_show_main_roads_overlay') ?? false; + _showHouseNumbersOverlay = + prefs.getBool('map_show_house_numbers_overlay') ?? false; + _showFireHazardZonesOverlay = + prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false; + _showHistoricalFiresOverlay = + prefs.getBool('map_show_historical_fires_overlay') ?? false; + _showFirebreaksOverlay = + prefs.getBool('map_show_firebreaks_overlay') ?? false; + _showKrasFireZonesOverlay = + prefs.getBool('map_show_kras_fire_zones_overlay') ?? false; + _showPlaceNamesOverlay = + prefs.getBool('map_show_place_names_overlay') ?? false; + _showMunicipalityBordersOverlay = + prefs.getBool('map_show_municipality_borders_overlay') ?? false; notifyListeners(); } Future _loadInitialState() async { - await Future.wait([loadOverlayState(), loadTrailSettings()]); - await loadRepeaterVisibilitySettings(); + await Future.wait([ + loadOverlayState(), + loadTrailSettings(), + loadRepeaterVisibilitySettings(), + _loadCustomMapState(), + ]); } - /// Save overlay state to SharedPreferences Future _saveOverlayState() async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay); - await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay); - await prefs.setBool('map_show_hiking_trails_overlay', _showHikingTrailsOverlay); + await prefs.setBool( + 'map_show_forest_roads_overlay', + _showForestRoadsOverlay, + ); + await prefs.setBool( + 'map_show_hiking_trails_overlay', + _showHikingTrailsOverlay, + ); await prefs.setBool('map_show_main_roads_overlay', _showMainRoadsOverlay); - await prefs.setBool('map_show_house_numbers_overlay', _showHouseNumbersOverlay); - await prefs.setBool('map_show_fire_hazard_zones_overlay', _showFireHazardZonesOverlay); - await prefs.setBool('map_show_historical_fires_overlay', _showHistoricalFiresOverlay); + await prefs.setBool( + 'map_show_house_numbers_overlay', + _showHouseNumbersOverlay, + ); + await prefs.setBool( + 'map_show_fire_hazard_zones_overlay', + _showFireHazardZonesOverlay, + ); + await prefs.setBool( + 'map_show_historical_fires_overlay', + _showHistoricalFiresOverlay, + ); await prefs.setBool('map_show_firebreaks_overlay', _showFirebreaksOverlay); - await prefs.setBool('map_show_kras_fire_zones_overlay', _showKrasFireZonesOverlay); + await prefs.setBool( + 'map_show_kras_fire_zones_overlay', + _showKrasFireZonesOverlay, + ); await prefs.setBool('map_show_place_names_overlay', _showPlaceNamesOverlay); - await prefs.setBool('map_show_municipality_borders_overlay', _showMunicipalityBordersOverlay); + await prefs.setBool( + 'map_show_municipality_borders_overlay', + _showMunicipalityBordersOverlay, + ); } - /// Toggle all contact trails on/off Future toggleAllContactTrails() async { _showAllContactTrails = !_showAllContactTrails; notifyListeners(); await _saveTrailSettings(); } - /// Load trail settings from SharedPreferences Future loadTrailSettings() async { final prefs = await SharedPreferences.getInstance(); - _showAllContactTrails = prefs.getBool('map_show_all_contact_trails') ?? true; // Default to true (show all) + _showAllContactTrails = + prefs.getBool('map_show_all_contact_trails') ?? true; notifyListeners(); } - /// Save trail settings to SharedPreferences Future _saveTrailSettings() async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails); @@ -413,48 +623,92 @@ class MapProvider with ChangeNotifier { notifyListeners(); } - /// Set imported trail (from GPX import) void setImportedTrail(LocationTrail trail) { _importedTrail = trail; notifyListeners(); } - /// Clear imported trail void clearImportedTrail() { _importedTrail = null; notifyListeners(); } - /// Replace current trail with imported trail void replaceCurrentTrailWithImport(LocationTrail importedTrail) { - // End current trail if active if (_currentTrail != null && _currentTrail!.isActive) { endTrail(); } - - // Set imported trail as current trail _currentTrail = importedTrail; _isTrailVisible = true; notifyListeners(); } - /// Enter download area selection mode with initial bounds void enterDownloadAreaMode(LatLngBounds initialBounds) { _isSelectingDownloadArea = true; _downloadAreaBounds = initialBounds; notifyListeners(); } - /// Exit download area selection mode void exitDownloadAreaMode() { _isSelectingDownloadArea = false; _downloadAreaBounds = null; notifyListeners(); } - /// Update the download area bounds (while dragging/resizing) void updateDownloadAreaBounds(LatLngBounds bounds) { _downloadAreaBounds = bounds; notifyListeners(); } + + Future _loadCustomMapState() async { + final prefs = await SharedPreferences.getInstance(); + final configJson = prefs.getString(_customMapConfigKey); + if (configJson != null && configJson.isNotEmpty) { + final decoded = jsonDecode(configJson); + if (decoded is Map) { + final config = CustomMapConfig.fromJson(decoded); + if (config != null && await File(config.filePath).exists()) { + _customMapConfig = config; + } else { + _customMapConfig = null; + } + } + } + _isUsingCustomMap = + (prefs.getBool(_customMapModeKey) ?? false) && _customMapConfig != null; + notifyListeners(); + } + + Future _saveCustomMapState() async { + final prefs = await SharedPreferences.getInstance(); + if (_customMapConfig == null) { + await prefs.remove(_customMapConfigKey); + } else { + await prefs.setString( + _customMapConfigKey, + jsonEncode(_customMapConfig!.toJson()), + ); + } + await prefs.setBool(_customMapModeKey, _isUsingCustomMap); + } + + Future<(int, int)> _decodeImageSize(Uint8List bytes) async { + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + return (frame.image.width, frame.image.height); + } + + String _normalizedExtension(String displayName) { + final dotIndex = displayName.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex == displayName.length - 1) { + return 'png'; + } + return displayName.substring(dotIndex + 1).toLowerCase(); + } + + Future _deleteFileIfExists(String path) async { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } } diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 017d1d2..ad6e6ea 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -2439,6 +2439,8 @@ class MessagesProvider with ChangeNotifier { text: message.text, isSarMarker: message.isSarMarker, sarGpsCoordinates: message.sarGpsCoordinates, + sarCustomMapPoint: message.sarCustomMapPoint, + sarCustomMapId: message.sarCustomMapId, sarNotes: message.sarNotes, sarCustomEmoji: message.sarCustomEmoji, sarColorIndex: message.sarColorIndex, diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index b86994b..fa7da15 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart' as flutter_map; @@ -16,6 +18,8 @@ import '../providers/drawing_provider.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; import '../models/contact.dart'; +import '../models/custom_map_config.dart'; +import '../models/map_coordinate_space.dart'; import '../models/sar_marker.dart'; import '../models/map_layer.dart'; import '../models/message.dart'; @@ -32,8 +36,10 @@ import '../widgets/map/drawing_toolbar.dart'; import '../widgets/map/location_trail_layer.dart'; import '../widgets/map/trail_controls.dart'; import '../widgets/map/map_message_overlay.dart'; +import '../widgets/messages/custom_map_sar_update_sheet.dart'; import '../widgets/messages/sar_update_sheet.dart'; import '../utils/key_comparison.dart'; +import '../utils/sar_message_parser.dart'; import '../l10n/app_localizations.dart'; class MapTab extends StatefulWidget { @@ -90,6 +96,10 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Saved map position (loaded from SharedPreferences) LatLng? _savedMapCenter; double? _savedMapZoom; + bool _isCalibratingCustomMap = false; + LatLng? _customMapCalibrationPointA; + LatLng? _customMapCalibrationPointB; + String _lastCustomMapViewportKey = ''; // Default center point (will be updated based on markers) static const LatLng _defaultCenter = LatLng( @@ -304,6 +314,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { Future _saveMapPosition() async { if (!_isMapReady) return; + if (_mapProvider?.isUsingCustomMap == true) return; try { final prefs = await SharedPreferences.getInstance(); final camera = _mapController.camera; @@ -317,18 +328,41 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { void _handleMapNavigation() { final mapProvider = context.read(); - if (mapProvider.targetLocation != null && _isMapReady) { - try { - _mapController.move( - mapProvider.targetLocation!, - mapProvider.targetZoom ?? _defaultZoom, + if (!_isMapReady) return; + + try { + final customMapConfig = mapProvider.customMapConfig; + + if (mapProvider.targetBounds != null) { + final bounds = + mapProvider.targetCoordinateSpace == MapCoordinateSpace.customMap && + customMapConfig != null + ? _toDisplayBounds(customMapConfig, mapProvider.targetBounds!) + : mapProvider.targetBounds!; + + _mapController.fitCamera( + CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)), ); - // Clear the navigation request after handling mapProvider.clearNavigation(); - } catch (e) { - // Map not ready yet, ignore - debugPrint('Map controller not ready for navigation: $e'); + return; } + + if (mapProvider.targetLocation != null) { + final location = + mapProvider.targetCoordinateSpace == MapCoordinateSpace.customMap && + customMapConfig != null + ? _toDisplayMapPoint(customMapConfig, mapProvider.targetLocation!) + : mapProvider.targetLocation!; + final zoom = + mapProvider.targetCoordinateSpace == MapCoordinateSpace.customMap + ? (mapProvider.targetZoom ?? 2.0).clamp(-4.0, 8.0).toDouble() + : mapProvider.targetZoom ?? _defaultZoom; + + _mapController.move(location, zoom); + mapProvider.clearNavigation(); + } + } catch (e) { + debugPrint('Map controller not ready for navigation: $e'); } } @@ -400,6 +434,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } void _showLayerSelector(BuildContext context) { + final rootContext = this.context; showModalBottomSheet( context: context, builder: (context) => Container( @@ -700,6 +735,154 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), ], + const Divider(), + Consumer( + builder: (context, mapProvider, _) { + final customMapConfig = mapProvider.customMapConfig; + final hasCustomMap = customMapConfig != null; + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const Icon(Icons.photo_library_outlined), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Custom picture map', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ], + ), + ), + if (!hasCustomMap) + ListTile( + leading: const Icon(Icons.add_photo_alternate), + title: const Text('Load from gallery'), + subtitle: const Text( + 'Use a cave map image instead of GPS tiles', + ), + onTap: () async { + final loaded = await mapProvider + .loadCustomMapFromGallery(); + if (!context.mounted || !rootContext.mounted) { + return; + } + Navigator.pop(context); + if (!loaded) { + return; + } + final drawingProvider = rootContext + .read(); + _syncDrawingContext( + drawingProvider, + mapProvider, + ); + }, + ) + else ...[ + ListTile( + leading: const Icon(Icons.image_outlined), + title: Text(customMapConfig.displayName), + subtitle: Text( + 'Map ID ${customMapConfig.mapId}${customMapConfig.isCalibrated ? ' β€’ Scale set' : ' β€’ Not calibrated'}', + ), + ), + SwitchListTile( + secondary: const Icon(Icons.map_outlined), + title: const Text('Use custom map'), + subtitle: const Text( + 'Hide GPS-based layers and work in image space', + ), + value: mapProvider.isUsingCustomMap, + onChanged: (value) async { + if (value) { + await mapProvider.enterCustomMapMode(); + } else { + await mapProvider.exitCustomMapMode(); + } + if (!context.mounted) return; + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.swap_horizontal_circle), + title: const Text('Replace image'), + subtitle: const Text( + 'Pick a different map from the gallery', + ), + onTap: () async { + await mapProvider.replaceCustomMap(); + if (!context.mounted) return; + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.straighten), + title: Text( + customMapConfig.isCalibrated + ? 'Update scale' + : 'Set scale', + ), + subtitle: const Text( + 'Tap two points on the image and enter meters', + ), + onTap: () async { + if (!mapProvider.isUsingCustomMap) { + await mapProvider.enterCustomMapMode(); + } + if (!context.mounted || !rootContext.mounted) { + return; + } + Navigator.pop(context); + _startCustomMapCalibration( + rootContext.read(), + ); + }, + ), + if (customMapConfig.isCalibrated) + ListTile( + leading: const Icon(Icons.clear), + title: const Text('Clear scale'), + onTap: () async { + await mapProvider.clearCustomMapCalibration(); + if (!context.mounted) return; + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon( + Icons.delete_outline, + color: Colors.red, + ), + title: Text( + 'Remove custom map', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + subtitle: const Text( + 'Deletes the saved image from this device', + ), + onTap: () async { + await mapProvider.removeCustomMap(); + if (!context.mounted) return; + Navigator.pop(context); + }, + ), + ], + ], + ); + }, + ), ], ), ), @@ -817,6 +1000,272 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } } + LatLng _toStoredMapPoint(CustomMapConfig config, LatLng point) { + return config.fromDisplayPoint(point); + } + + LatLng _toDisplayMapPoint(CustomMapConfig config, LatLng point) { + return config.toDisplayPoint(point); + } + + LatLngBounds _toDisplayBounds(CustomMapConfig config, LatLngBounds bounds) { + return LatLngBounds.fromPoints([ + _toDisplayMapPoint(config, LatLng(bounds.north, bounds.west)), + _toDisplayMapPoint(config, LatLng(bounds.north, bounds.east)), + _toDisplayMapPoint(config, LatLng(bounds.south, bounds.west)), + _toDisplayMapPoint(config, LatLng(bounds.south, bounds.east)), + ]); + } + + void _syncDrawingContext( + DrawingProvider drawingProvider, + MapProvider mapProvider, + ) { + final coordinateSpace = mapProvider.isUsingCustomMap + ? MapCoordinateSpace.customMap + : MapCoordinateSpace.geo; + final mapId = mapProvider.isUsingCustomMap + ? mapProvider.customMapConfig?.mapId + : null; + final metersPerPixel = mapProvider.isUsingCustomMap + ? mapProvider.customMapConfig?.metersPerPixel + : null; + + if (drawingProvider.activeCoordinateSpace == coordinateSpace && + drawingProvider.activeMapId == mapId && + drawingProvider.activeMetersPerPixel == metersPerPixel) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + drawingProvider.setMapContext( + coordinateSpace: coordinateSpace, + mapId: mapId, + metersPerPixel: metersPerPixel, + ); + }); + } + + void _ensureCustomMapViewport( + MapProvider mapProvider, + CustomMapConfig? customMapConfig, + ) { + final nextKey = mapProvider.isUsingCustomMap && customMapConfig != null + ? customMapConfig.mapId + : ''; + if (_lastCustomMapViewportKey == nextKey) { + return; + } + _lastCustomMapViewportKey = nextKey; + + if (!_isMapReady || + customMapConfig == null || + !mapProvider.isUsingCustomMap) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_isMapReady || !mapProvider.isUsingCustomMap) { + return; + } + try { + _mapController.fitCamera( + CameraFit.bounds( + bounds: customMapConfig.displayBounds, + padding: const EdgeInsets.all(24), + ), + ); + } catch (e) { + debugPrint('Failed to fit custom map viewport: $e'); + } + }); + } + + void _startCustomMapCalibration(DrawingProvider drawingProvider) { + if (!mounted) return; + drawingProvider.exitDrawingMode(); + setState(() { + _droppedPinLocation = null; + _isDraggingPin = false; + _isCalibratingCustomMap = true; + _customMapCalibrationPointA = null; + _customMapCalibrationPointB = null; + }); + } + + void _stopCustomMapCalibration() { + if (!mounted) return; + setState(() { + _isCalibratingCustomMap = false; + _customMapCalibrationPointA = null; + _customMapCalibrationPointB = null; + }); + } + + Future _handleCustomMapCalibrationTap( + MapProvider mapProvider, + LatLng storedPoint, + ) async { + if (!_isCalibratingCustomMap) { + return; + } + + if (_customMapCalibrationPointA == null) { + setState(() { + _customMapCalibrationPointA = storedPoint; + }); + return; + } + + if (_customMapCalibrationPointB == null) { + setState(() { + _customMapCalibrationPointB = storedPoint; + }); + + final controller = TextEditingController(); + final meters = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Set map scale'), + content: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + autofocus: true, + decoration: const InputDecoration( + labelText: 'Distance in meters', + hintText: '25', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(dialogContext)!.cancel), + ), + FilledButton( + onPressed: () { + final value = double.tryParse(controller.text.trim()); + if (value == null || value <= 0) { + return; + } + Navigator.pop(dialogContext, value); + }, + child: Text(AppLocalizations.of(dialogContext)!.save), + ), + ], + ); + }, + ); + + if (meters == null || meters <= 0) { + if (mounted) { + setState(() { + _customMapCalibrationPointB = null; + }); + } + return; + } + + final pointA = _customMapCalibrationPointA!; + final pointB = _customMapCalibrationPointB!; + final dy = pointB.latitude - pointA.latitude; + final dx = pointB.longitude - pointA.longitude; + final pixelDistance = math.sqrt((dx * dx) + (dy * dy)); + if (pixelDistance <= 0) { + return; + } + + await mapProvider.setCustomMapCalibration( + pointA: pointA, + pointB: pointB, + metersPerPixel: meters / pixelDistance, + ); + + _stopCustomMapCalibration(); + + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Custom map scale saved'))); + } + } + + void _showCustomMapSarDialogWithPoint( + CustomMapConfig customMapConfig, + LatLng storedPoint, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => CustomMapSarUpdateSheet( + mapName: customMapConfig.displayName, + mapId: customMapConfig.mapId, + pointLabel: + 'Point: ${storedPoint.latitude.toStringAsFixed(0)}, ${storedPoint.longitude.toStringAsFixed(0)}', + onSend: + ( + emoji, + name, + roomPublicKey, + sendToChannel, + sendToAllContacts, + colorIndex, + ) async { + await _sendCustomMapSarMessage( + emoji, + name, + storedPoint, + customMapConfig.mapId, + roomPublicKey, + sendToChannel, + sendToAllContacts, + colorIndex, + ); + }, + ), + ); + } + + Widget _buildTaggedPointMarker({ + required String label, + required Color color, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + padding: const EdgeInsets.all(6), + child: const Icon(Icons.place, color: Colors.white, size: 18), + ), + ], + ); + } + Future _showSarMarkerActions( SarMarker marker, MessagesProvider messagesProvider, @@ -850,9 +1299,19 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), const SizedBox(height: 12), Text( - '${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}', + marker.coordinateSpace == MapCoordinateSpace.customMap + ? 'Point ${marker.location.latitude.toStringAsFixed(0)}, ${marker.location.longitude.toStringAsFixed(0)}' + : '${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}', style: theme.textTheme.bodyMedium, ), + if (marker.coordinateSpace == MapCoordinateSpace.customMap && + marker.mapId != null) ...[ + const SizedBox(height: 4), + Text( + 'Map ID ${marker.mapId}', + style: theme.textTheme.bodySmall, + ), + ], const SizedBox(height: 4), Text( marker.senderName != null @@ -1041,6 +1500,53 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { bool sendToAllContacts, int colorIndex, ) async { + final sarMessage = SarMessageParser.createSarMessage( + type: SarMarkerType.fromEmoji(emoji), + location: LatLng(position.latitude, position.longitude), + notes: name, + colorIndex: colorIndex, + ); + + await _sendSarPayload( + sarMessage: sarMessage, + roomPublicKey: roomPublicKey, + sendToChannel: sendToChannel, + sendToAllContacts: sendToAllContacts, + ); + } + + Future _sendCustomMapSarMessage( + String emoji, + String name, + LatLng storedPoint, + String mapId, + Uint8List? roomPublicKey, + bool sendToChannel, + bool sendToAllContacts, + int colorIndex, + ) async { + final sarMessage = SarMessageParser.createCustomMapSarMessage( + emoji: emoji, + mapId: mapId, + point: storedPoint, + notes: name, + colorIndex: colorIndex, + ); + + await _sendSarPayload( + sarMessage: sarMessage, + roomPublicKey: roomPublicKey, + sendToChannel: sendToChannel, + sendToAllContacts: sendToAllContacts, + ); + } + + Future _sendSarPayload({ + required String sarMessage, + required Uint8List? roomPublicKey, + required bool sendToChannel, + required bool sendToAllContacts, + }) async { final connectionProvider = context.read(); final messagesProvider = context.read(); @@ -1067,13 +1573,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } try { - // New format: S:::,: - // Round coordinates to 5 decimal places (~1m accuracy) since most GPS is only that accurate - final sarMessage = - 'S:$emoji:$colorIndex:${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}:$name'; - if (sendToAllContacts) { - // Send to all chat contacts (ContactType.chat) final contactsProvider = context.read(); final chatContacts = contactsProvider.chatContacts; @@ -1088,13 +1588,11 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { return; } - // Create a single grouped message instead of multiple individual messages final groupId = '${DateTime.now().millisecondsSinceEpoch}_group'; final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final devicePublicKey = connectionProvider.deviceInfo.publicKey; final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); - // Create recipient list final recipients = chatContacts.map((contact) { return MessageRecipient( publicKey: contact.publicKey, @@ -1104,7 +1602,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); }).toList(); - // Create single grouped message final groupedMessage = Message( id: groupId, messageType: MessageType.contact, @@ -1119,22 +1616,17 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { recipients: recipients, ); - // Add the grouped message to the list messagesProvider.addSentMessage(groupedMessage); - // Send to each contact and track status int successCount = 0; for (final contact in chatContacts) { final individualMessageId = '${groupId}_${contact.publicKeyShort}'; - - // Register this individual send as part of the grouped message messagesProvider.registerGroupedMessageSend( individualMessageId, groupId, contact.publicKey, ); - // Send SAR message to contact (with ACK tracking) final sentSuccessfully = await connectionProvider.sendTextMessage( contactPublicKey: contact.publicKey, text: sarMessage, @@ -1145,7 +1637,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { if (sentSuccessfully) { successCount++; } else { - // Update recipient status in grouped message messagesProvider.updateGroupedMessageRecipientStatus( groupId, contact.publicKey, @@ -1153,10 +1644,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); } - // Add 1 second delay between sends to ensure: - // 1. Different timestamps (messages sent in different seconds) - // 2. Radio has time to fully process previous message and assign ACK tag - // This ensures each message gets a unique ACK tag from the radio if (contact != chatContacts.last) { await Future.delayed(const Duration(seconds: 1)); } @@ -1174,17 +1661,16 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { duration: const Duration(seconds: 2), ), ); - } else if (sendToChannel) { - // Create message ID + return; + } + + if (sendToChannel) { final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_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, @@ -1196,13 +1682,10 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { receivedAt: DateTime.now(), deliveryStatus: MessageDeliveryStatus.sending, channelIdx: 0, - // SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider ); - // Add to messages list with "sending" status messagesProvider.addSentMessage(sentMessage); - // Send to public channel (ephemeral, over-the-air only) await connectionProvider.sendChannelMessage( channelIdx: 0, text: sarMessage, @@ -1217,62 +1700,54 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { duration: Duration(seconds: 2), ), ); - } else { - // Create message ID - final messageId = '${DateTime.now().millisecondsSinceEpoch}_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 with recipient public key for retry support - final sentMessage = Message( - id: messageId, - messageType: MessageType.contact, - senderPublicKeyPrefix: senderPublicKeyPrefix, - pathLen: 0, - textType: MessageTextType.plain, - senderTimestamp: timestamp, - text: sarMessage, - receivedAt: DateTime.now(), - deliveryStatus: MessageDeliveryStatus.sending, - recipientPublicKey: roomPublicKey, // Store recipient for retry - // SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider - ); - - // Look up the room contact for path logging - final contactsProvider = context.read(); - final roomContact = contactsProvider.contacts.where((c) { - return c.publicKey.length >= roomPublicKey!.length && - c.publicKey.matches(roomPublicKey); - }).firstOrNull; - - // Add to messages list with "sending" status - messagesProvider.addSentMessage(sentMessage, contact: roomContact); - - // Send SAR message to selected room (persisted and immutable) - final sentSuccessfully = await connectionProvider.sendTextMessage( - contactPublicKey: roomPublicKey!, - text: sarMessage, - messageId: messageId, // Pass message ID so it can be tracked - contact: roomContact, // Include contact for path status logging - ); - - if (!sentSuccessfully) { - // Mark message as failed if sending failed - messagesProvider.markMessageFailed(messageId); - } - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('SAR marker sent to room'), - backgroundColor: Colors.green, - duration: Duration(seconds: 2), - ), - ); + return; } + + final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + final sentMessage = Message( + id: messageId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: sarMessage, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: roomPublicKey, + ); + + final contactsProvider = context.read(); + final roomContact = contactsProvider.contacts.where((c) { + return c.publicKey.length >= roomPublicKey!.length && + c.publicKey.matches(roomPublicKey); + }).firstOrNull; + + messagesProvider.addSentMessage(sentMessage, contact: roomContact); + + final sentSuccessfully = await connectionProvider.sendTextMessage( + contactPublicKey: roomPublicKey!, + text: sarMessage, + messageId: messageId, + contact: roomContact, + ); + + if (!sentSuccessfully) { + messagesProvider.markMessageFailed(messageId); + } + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('SAR marker sent to room'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -1294,26 +1769,73 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { DrawingProvider, MapProvider >( - builder: ( - context, - contactsProvider, - messagesProvider, - drawingProvider, - mapProvider, - child, - ) { + builder: (context, contactsProvider, messagesProvider, drawingProvider, mapProvider, child) { + final customMapConfig = mapProvider.customMapConfig; + final isCustomMapMode = + mapProvider.isUsingCustomMap && customMapConfig != null; + if (!isCustomMapMode && _isCalibratingCustomMap) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _stopCustomMapCalibration(); + } + }); + } + _syncDrawingContext(drawingProvider, mapProvider); + _ensureCustomMapViewport(mapProvider, customMapConfig); + final allContactsWithLocation = contactsProvider.contactsWithLocation; final contactsWithLocation = mapProvider.hideRepeatersOnMap ? allContactsWithLocation - .where((contact) => !contact.isRepeater) - .toList() + .where((contact) => !contact.isRepeater) + .toList() : allContactsWithLocation; // Filter SAR markers based on visibility toggle final allSarMarkers = messagesProvider.sarMarkers; - final sarMarkers = drawingProvider.showSarMarkers + final sarMarkers = !drawingProvider.showSarMarkers + ? [] + : isCustomMapMode ? allSarMarkers - : []; - final center = _calculateCenter(contactsWithLocation, sarMarkers); + .where( + (marker) => + marker.coordinateSpace == + MapCoordinateSpace.customMap && + marker.mapId == customMapConfig.mapId, + ) + .toList() + : allSarMarkers + .where( + (marker) => + marker.coordinateSpace == MapCoordinateSpace.geo, + ) + .toList(); + final center = isCustomMapMode + ? customMapConfig.displayBounds.center + : _calculateCenter(contactsWithLocation, sarMarkers); + final pointTransformer = isCustomMapMode + ? (LatLng point) => _toDisplayMapPoint(customMapConfig, point) + : null; + final measurementPoint1 = + isCustomMapMode && drawingProvider.measurementPoint1 != null + ? _toDisplayMapPoint( + customMapConfig, + drawingProvider.measurementPoint1!, + ) + : drawingProvider.measurementPoint1; + final measurementPoint2 = + isCustomMapMode && drawingProvider.measurementPoint2 != null + ? _toDisplayMapPoint( + customMapConfig, + drawingProvider.measurementPoint2!, + ) + : drawingProvider.measurementPoint2; + final calibrationPointA = + isCustomMapMode && _customMapCalibrationPointA != null + ? _toDisplayMapPoint(customMapConfig, _customMapCalibrationPointA!) + : null; + final calibrationPointB = + isCustomMapMode && _customMapCalibrationPointB != null + ? _toDisplayMapPoint(customMapConfig, _customMapCalibrationPointB!) + : null; return Stack( children: [ @@ -1321,7 +1843,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { Listener( onPointerMove: (PointerMoveEvent event) { // Track pointer movement for mobile drag (onPointerHover doesn't work on mobile) - if (_isDraggingPin) { + if (_isDraggingPin && !isCustomMapMode) { final latLng = _mapController.camera.screenOffsetToLatLng( event.localPosition, ); @@ -1333,16 +1855,30 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { child: FlutterMap( mapController: _mapController, options: MapOptions( - // Use the layer's CRS if it has one (for WMS layers), otherwise default to EPSG:3857 - crs: _currentLayer.crs ?? const Epsg3857(), - // Use saved position if available, otherwise use calculated center - initialCenter: _savedMapCenter ?? center, - initialZoom: _savedMapZoom ?? _defaultZoom, - minZoom: 0, // Allow full zoom out to see world view - maxZoom: - _currentLayer.maxZoom, // Respect current layer's maximum + crs: isCustomMapMode + ? const CrsSimple() + : _currentLayer.crs ?? const Epsg3857(), + initialCenter: isCustomMapMode + ? center + : (_savedMapCenter ?? center), + initialZoom: isCustomMapMode + ? 0.0 + : (_savedMapZoom ?? _defaultZoom), + initialCameraFit: isCustomMapMode + ? CameraFit.bounds( + bounds: customMapConfig.displayBounds, + padding: const EdgeInsets.all(24), + ) + : null, + minZoom: isCustomMapMode ? -4 : 0, + maxZoom: isCustomMapMode ? 8 : _currentLayer.maxZoom, + cameraConstraint: isCustomMapMode + ? CameraConstraint.contain( + bounds: customMapConfig.displayBounds, + ) + : const CameraConstraint.unconstrained(), interactionOptions: InteractionOptions( - flags: _isDraggingPin + flags: _isDraggingPin && !isCustomMapMode ? InteractiveFlag .none // Disable map interaction while dragging pin : InteractiveFlag.all, @@ -1360,18 +1896,26 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } }, onLongPress: (tapPosition, point) { + if (_isCalibratingCustomMap) { + return; + } + + final mapPoint = isCustomMapMode + ? _toStoredMapPoint(customMapConfig, point) + : point; + // Handle measurement mode - set measurement points only, no SAR marker if (drawingProvider.drawingMode == DrawingMode.measure) { if (drawingProvider.measurementPoint1 == null) { // Set first measurement point - drawingProvider.setMeasurementPoint1(point); + drawingProvider.setMeasurementPoint1(mapPoint); } else if (drawingProvider.measurementPoint2 == null) { // Set second measurement point - drawingProvider.setMeasurementPoint2(point); + drawingProvider.setMeasurementPoint2(mapPoint); } else { // Clear and start new measurement drawingProvider.clearMeasurement(); - drawingProvider.setMeasurementPoint1(point); + drawingProvider.setMeasurementPoint1(mapPoint); } // Don't drop SAR marker pin in measurement mode return; @@ -1380,6 +1924,14 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Skip if in other drawing modes if (drawingProvider.isDrawing) return; + if (isCustomMapMode) { + _showCustomMapSarDialogWithPoint( + customMapConfig, + mapPoint, + ); + return; + } + // Drop a pin at long press location for SAR marker creation if (_droppedPinLocation == null) { setState(() { @@ -1388,6 +1940,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } }, onPointerDown: (event, point) { + if (isCustomMapMode) { + return; + } // Check if pointer is near the pin to start dragging if (_droppedPinLocation != null) { final distance = _calculateDistanceInMeters( @@ -1405,15 +1960,19 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } }, onPointerHover: (event, point) { + final mapPoint = isCustomMapMode + ? _toStoredMapPoint(customMapConfig, point) + : point; + // Update rectangle preview while dragging if (drawingProvider.drawingMode == DrawingMode.rectangle && drawingProvider.rectangleStartPoint != null) { - drawingProvider.updateRectangleEndPoint(point); + drawingProvider.updateRectangleEndPoint(mapPoint); return; } // Update pin location while dragging - if (_isDraggingPin) { + if (_isDraggingPin && !isCustomMapMode) { setState(() { _droppedPinLocation = point; }); @@ -1428,28 +1987,41 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } }, onTap: (tapPosition, point) { + final mapPoint = isCustomMapMode + ? _toStoredMapPoint(customMapConfig, point) + : point; + + if (_isCalibratingCustomMap && isCustomMapMode) { + _handleCustomMapCalibrationTap(mapProvider, mapPoint); + return; + } + // Handle drawing mode taps if (drawingProvider.drawingMode == DrawingMode.line) { if (drawingProvider.currentLinePoints.isEmpty) { // Start new line - drawingProvider.startLine(point); + drawingProvider.startLine(mapPoint); } else { // Add point to current line - drawingProvider.addLinePoint(point); + drawingProvider.addLinePoint(mapPoint); } return; } else if (drawingProvider.drawingMode == DrawingMode.rectangle) { if (drawingProvider.rectangleStartPoint == null) { // Start rectangle - drawingProvider.startRectangle(point); + drawingProvider.startRectangle(mapPoint); } else { // Complete rectangle - drawingProvider.completeRectangle(point); + drawingProvider.completeRectangle(mapPoint); } return; } + if (isCustomMapMode) { + return; + } + // Clear dropped pin if tapping elsewhere (not on the pin itself) if (_droppedPinLocation != null && !_isDraggingPin) { // Check if tap is far from the pin @@ -1470,7 +2042,18 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), children: [ // Render raster or WMS tile layer based on layer type - if (_currentLayer.isWms && + if (isCustomMapMode) + OverlayImageLayer( + overlayImages: [ + OverlayImage( + bounds: customMapConfig.displayBounds, + imageProvider: FileImage( + File(customMapConfig.filePath), + ), + ), + ], + ) + else if (_currentLayer.isWms && _currentLayer.wmsBaseUrl != null && _currentLayer.crs != null) // WMS Base Layer (e.g., Slovenian Aerial Imagery) @@ -1500,387 +2083,399 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { userAgentPackageName: 'com.meshcore.sar', maxZoom: _currentLayer.maxZoom, ), - // WMS Overlays (rendered after base layer, before polylines) - // Note: These overlays only work with EPSG:3794 CRS (Slovenian coordinate system) - // Cadastral parcels overlay - Consumer( - builder: (context, mapProvider, _) { - // Only show if enabled and map is using Slovenian CRS - if (!mapProvider.showCadastralOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: - 'https://prostor.zgs.gov.si/geowebcache/service/wms?', - layers: const ['pregledovalnik:kn_parcele'], - styles: const ['parcele'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ Cadastral overlay tile error at ${tile.coordinates}: $error', - ); - if (stackTrace != null) { - debugPrint(' StackTrace: $stackTrace'); - } - }, - ); - }, - ), - // Forest roads overlay - Consumer( - builder: (context, mapProvider, _) { - // Only show if enabled and map is using Slovenian CRS - if (!mapProvider.showForestRoadsOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:gozdne_ceste'], - styles: const ['gozdne_ceste'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ Forest roads overlay tile error at ${tile.coordinates}: $error', - ); - if (stackTrace != null) { - debugPrint(' StackTrace: $stackTrace'); - } - }, - ); - }, - ), - // Hiking trails overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showHikingTrailsOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const [ - 'pregledovalnik:KGI_LINIJE_PLANINSKE_POTI_G', - ], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ Hiking trails overlay tile error at ${tile.coordinates}: $error', - ); - }, - ); - }, - ), - // Main roads overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showMainRoadsOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:KGI_LINIJE_CESTE_G'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ Main roads overlay tile error at ${tile.coordinates}: $error', - ); - }, - ); - }, - ), - // House numbers overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showHouseNumbersOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:NEP_HISNE_STEVILKE'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ House numbers overlay tile error at ${tile.coordinates}: $error', - ); - }, - ); - }, - ), - // Fire hazard zones overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showFireHazardZonesOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:pozarna_ogrozenost'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ Fire hazard zones overlay tile error at ${tile.coordinates}: $error', - ); - }, - ); - }, - ), - // Historical fires overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showHistoricalFiresOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:gozdni_pozari'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ Historical fires overlay tile error at ${tile.coordinates}: $error', - ); - }, - ); - }, - ), - // Firebreaks overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showFirebreaksOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:protipozarne_preseke'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ Firebreaks overlay tile error at ${tile.coordinates}: $error', - ); - }, - ); - }, - ), - // Kras fire zones overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showKrasFireZonesOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:pozarisce_kras'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ Kras fire zones overlay tile error at ${tile.coordinates}: $error', - ); - }, - ); - }, - ), - // Place names overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showPlaceNamesOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:zemljepisna_imena'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ Place names overlay tile error at ${tile.coordinates}: $error', - ); - }, - ); - }, - ), - // Municipality borders overlay - Consumer( - builder: (context, mapProvider, _) { - if (!mapProvider.showMunicipalityBordersOverlay || - _currentLayer.crs == null) { - return const SizedBox.shrink(); - } - return flutter_map.TileLayer( - wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', - layers: const ['pregledovalnik:NEP_RPE_OBCINE'], - styles: const ['obcine'], - format: 'image/png', - transparent: true, - crs: slovenianCrs, - ), - tileProvider: _tileProvider, - userAgentPackageName: 'com.meshcore.sar', - maxZoom: 19, - errorTileCallback: (tile, error, stackTrace) { - debugPrint( - 'πŸ”΄ Municipality borders overlay tile error at ${tile.coordinates}: $error', - ); - }, - ); - }, - ), - // Imported trail layer (rendered at bottom for reference) - Consumer( - builder: (context, mapProvider, _) { - if (mapProvider.importedTrail == null || - mapProvider.importedTrail!.points.length < 2) { - return const SizedBox.shrink(); - } - - return PolylineLayer( - polylines: [ - Polyline( - points: mapProvider.importedTrail!.latLngPoints, - color: Colors.green.withValues(alpha: 0.7), - strokeWidth: 3.0, - borderColor: Colors.white.withValues(alpha: 0.4), - borderStrokeWidth: 1.0, - // DOTTED pattern to distinguish from other trails - pattern: StrokePattern.dotted(spacingFactor: 2), + if (!isCustomMapMode) ...[ + // WMS Overlays (rendered after base layer, before polylines) + // Note: These overlays only work with EPSG:3794 CRS (Slovenian coordinate system) + // Cadastral parcels overlay + Consumer( + builder: (context, mapProvider, _) { + // Only show if enabled and map is using Slovenian CRS + if (!mapProvider.showCadastralOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + layers: const ['pregledovalnik:kn_parcele'], + styles: const ['parcele'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, ), - ], - ); - }, - ), - // Contact trail polylines (rendered before user trail and markers) - Consumer( - builder: (context, mapProvider, _) { - // Determine which contacts to show trails for - final contactsToShow = mapProvider.showAllContactTrails - ? contactsWithLocation // Show all when master toggle is ON - : contactsWithLocation.where( - (contact) => mapProvider.isContactPathVisible( - contact.publicKeyHex, - ), - ); // Individual toggles + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ Cadastral overlay tile error at ${tile.coordinates}: $error', + ); + if (stackTrace != null) { + debugPrint(' StackTrace: $stackTrace'); + } + }, + ); + }, + ), + // Forest roads overlay + Consumer( + builder: (context, mapProvider, _) { + // Only show if enabled and map is using Slovenian CRS + if (!mapProvider.showForestRoadsOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:gozdne_ceste'], + styles: const ['gozdne_ceste'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ Forest roads overlay tile error at ${tile.coordinates}: $error', + ); + if (stackTrace != null) { + debugPrint(' StackTrace: $stackTrace'); + } + }, + ); + }, + ), + // Hiking trails overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showHikingTrailsOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const [ + 'pregledovalnik:KGI_LINIJE_PLANINSKE_POTI_G', + ], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ Hiking trails overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Main roads overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showMainRoadsOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:KGI_LINIJE_CESTE_G'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ Main roads overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // House numbers overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showHouseNumbersOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:NEP_HISNE_STEVILKE'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ House numbers overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Fire hazard zones overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showFireHazardZonesOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:pozarna_ogrozenost'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ Fire hazard zones overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Historical fires overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showHistoricalFiresOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:gozdni_pozari'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ Historical fires overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Firebreaks overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showFirebreaksOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const [ + 'pregledovalnik:protipozarne_preseke', + ], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ Firebreaks overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Kras fire zones overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showKrasFireZonesOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:pozarisce_kras'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ Kras fire zones overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Place names overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showPlaceNamesOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:zemljepisna_imena'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ Place names overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Municipality borders overlay + Consumer( + builder: (context, mapProvider, _) { + if (!mapProvider.showMunicipalityBordersOverlay || + _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: + 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:NEP_RPE_OBCINE'], + styles: const ['obcine'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileProvider, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint( + 'πŸ”΄ Municipality borders overlay tile error at ${tile.coordinates}: $error', + ); + }, + ); + }, + ), + // Imported trail layer (rendered at bottom for reference) + Consumer( + builder: (context, mapProvider, _) { + if (mapProvider.importedTrail == null || + mapProvider.importedTrail!.points.length < 2) { + return const SizedBox.shrink(); + } - return PolylineLayer( - polylines: contactsToShow - .where( - (contact) => contact.advertHistory.length >= 2, - ) - .map((contact) { - // Use TrailColorService for consistent, emoji-based colors - final color = TrailColorService.getTrailColor( - contact, - ); + return PolylineLayer( + polylines: [ + Polyline( + points: mapProvider.importedTrail!.latLngPoints, + color: Colors.green.withValues(alpha: 0.7), + strokeWidth: 3.0, + borderColor: Colors.white.withValues(alpha: 0.4), + borderStrokeWidth: 1.0, + // DOTTED pattern to distinguish from other trails + pattern: StrokePattern.dotted(spacingFactor: 2), + ), + ], + ); + }, + ), + // Contact trail polylines (rendered before user trail and markers) + Consumer( + builder: (context, mapProvider, _) { + // Determine which contacts to show trails for + final contactsToShow = mapProvider.showAllContactTrails + ? contactsWithLocation // Show all when master toggle is ON + : contactsWithLocation.where( + (contact) => mapProvider.isContactPathVisible( + contact.publicKeyHex, + ), + ); // Individual toggles - return Polyline( - points: contact.advertHistory - .map((advert) => advert.location) - .toList(), - color: color.withValues( - alpha: 0.95, - ), // More opaque for better visibility - strokeWidth: - 4.5, // Thicker for better visibility on all map backgrounds - borderColor: Colors.white.withValues( - alpha: 0.6, - ), // Stronger border contrast - borderStrokeWidth: 2.0, // Wider border - // DASHED pattern to distinguish from solid user trail - pattern: StrokePattern.dashed(segments: [8, 4]), - ); - }) - .toList(), - ); - }, - ), - // Location trail layer (rendered after paths, before drawings) - const LocationTrailLayer(), + return PolylineLayer( + polylines: contactsToShow + .where( + (contact) => contact.advertHistory.length >= 2, + ) + .map((contact) { + // Use TrailColorService for consistent, emoji-based colors + final color = TrailColorService.getTrailColor( + contact, + ); + + return Polyline( + points: contact.advertHistory + .map((advert) => advert.location) + .toList(), + color: color.withValues( + alpha: 0.95, + ), // More opaque for better visibility + strokeWidth: + 4.5, // Thicker for better visibility on all map backgrounds + borderColor: Colors.white.withValues( + alpha: 0.6, + ), // Stronger border contrast + borderStrokeWidth: 2.0, // Wider border + // DASHED pattern to distinguish from solid user trail + pattern: StrokePattern.dashed( + segments: [8, 4], + ), + ); + }) + .toList(), + ); + }, + ), + // Location trail layer (rendered after paths, before drawings) + const LocationTrailLayer(), + ], // Measurement line layer (rendered before drawings) - if (drawingProvider.measurementPoint1 != null && - drawingProvider.measurementPoint2 != null) + if (measurementPoint1 != null && measurementPoint2 != null) PolylineLayer( polylines: [ Polyline( - points: [ - drawingProvider.measurementPoint1!, - drawingProvider.measurementPoint2!, - ], + points: [measurementPoint1, measurementPoint2], color: Colors.yellow.withValues(alpha: 0.8), strokeWidth: 3.0, borderColor: Colors.black.withValues(alpha: 0.5), @@ -1889,33 +2484,48 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), ], ), + if (calibrationPointA != null && calibrationPointB != null) + PolylineLayer( + polylines: [ + Polyline( + points: [calibrationPointA, calibrationPointB], + color: Colors.lightBlueAccent.withValues(alpha: 0.9), + strokeWidth: 3.0, + borderColor: Colors.black.withValues(alpha: 0.35), + borderStrokeWidth: 1.0, + ), + ], + ), // Drawing layer (rendered after paths, before markers) DrawingLayer( drawings: drawingProvider.drawings, previewDrawing: drawingProvider.getPreviewDrawing(), + pointTransformer: pointTransformer, ), MarkerLayer( markers: [ // Contact markers - ..._markerService.generateContactMarkers( - contacts: contactsWithLocation, - context: context, - mapRotation: _getMapRotation(), - userPosition: _locationService.currentPosition, - onTap: (contact) { - _showDetailedCompassWithContact( - context, - contactsWithLocation, - messagesProvider.sarMarkers, - contact, - ); - }, - ), + if (!isCustomMapMode) + ..._markerService.generateContactMarkers( + contacts: contactsWithLocation, + context: context, + mapRotation: _getMapRotation(), + userPosition: _locationService.currentPosition, + onTap: (contact) { + _showDetailedCompassWithContact( + context, + contactsWithLocation, + sarMarkers, + contact, + ); + }, + ), // SAR markers ..._markerService.generateSarMarkers( sarMarkers: sarMarkers, context: context, mapRotation: _getMapRotation(), + pointTransformer: pointTransformer, onTap: (marker) { _showSarMarkerActions( marker, @@ -1925,21 +2535,22 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, ), // User location marker with directional pointer - if (_markerService.generateUserLocationMarker( - position: _locationService.currentPosition, - heading: _currentHeading, - context: context, - ) != - null) + if (!isCustomMapMode && + _markerService.generateUserLocationMarker( + position: _locationService.currentPosition, + heading: _currentHeading, + context: context, + ) != + null) _markerService.generateUserLocationMarker( position: _locationService.currentPosition, heading: _currentHeading, context: context, )!, // Measurement point 1 marker - if (drawingProvider.measurementPoint1 != null) + if (measurementPoint1 != null) Marker( - point: drawingProvider.measurementPoint1!, + point: measurementPoint1, width: 60, height: 80, rotate: false, @@ -1992,9 +2603,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), ), // Measurement point 2 marker - if (drawingProvider.measurementPoint2 != null) + if (measurementPoint2 != null) Marker( - point: drawingProvider.measurementPoint2!, + point: measurementPoint2, width: 60, height: 80, rotate: false, @@ -2046,8 +2657,30 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ], ), ), + if (calibrationPointA != null) + Marker( + point: calibrationPointA, + width: 60, + height: 70, + rotate: false, + child: _buildTaggedPointMarker( + label: 'A', + color: Colors.lightBlue, + ), + ), + if (calibrationPointB != null) + Marker( + point: calibrationPointB, + width: 60, + height: 70, + rotate: false, + child: _buildTaggedPointMarker( + label: 'B', + color: Colors.lightBlue, + ), + ), // Dropped pin marker with label - if (_droppedPinLocation != null) + if (!isCustomMapMode && _droppedPinLocation != null) Marker( key: _pinMarkerKey, point: _droppedPinLocation!, @@ -2136,6 +2769,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { DrawingMarkersLayer( drawings: drawingProvider.drawings, showDeleteButtons: drawingProvider.isDrawing, + pointTransformer: pointTransformer, onDeleteDrawing: (drawingId) { drawingProvider.removeDrawing(drawingId); }, @@ -2202,7 +2836,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), ), // Compass widget - top right (hidden in fullscreen mode) - if (!_isFullscreen) + if (!_isFullscreen && !isCustomMapMode) Positioned( top: 16, right: 16, @@ -2210,7 +2844,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { onTap: () => _showDetailedCompass( context, contactsWithLocation, - messagesProvider.sarMarkers, + sarMarkers, ), child: CompassWidget( heading: _currentHeading ?? 0, @@ -2218,6 +2852,54 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), ), ), + if (isCustomMapMode && + !_isFullscreen && + drawingProvider.drawingMode != DrawingMode.measure) + Positioned( + top: 16, + left: 16, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.96), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + customMapConfig.isCalibrated + ? 'Scale set' + : 'Not calibrated', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (_isCalibratingCustomMap) ...[ + const SizedBox(height: 4), + Text( + _customMapCalibrationPointA == null + ? 'Tap point A' + : _customMapCalibrationPointB == null + ? 'Tap point B' + : 'Enter distance', + ), + ], + ], + ), + ), + ), // Measurement distance overlay if (drawingProvider.drawingMode == DrawingMode.measure) Positioned( @@ -2263,6 +2945,19 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ], ), const SizedBox(height: 8), + if (isCustomMapMode) ...[ + Text( + customMapConfig.isCalibrated + ? 'Scale set' + : 'Set scale in Layers to enable distance', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ], if (drawingProvider.measuredDistance != null) Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2323,7 +3018,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { const DrawingToolbar(), const SizedBox(height: 8), // Hide other buttons when in drawing mode - if (!drawingProvider.isDrawing) ...[ + if (!drawingProvider.isDrawing && !isCustomMapMode) ...[ // Current Location - always center to GPS FloatingActionButton.small( heroTag: 'center_map', @@ -2440,8 +3135,10 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Continue with other buttons when not in drawing mode if (!drawingProvider.isDrawing) ...[ // Trail controls button - const TrailControls(), - const SizedBox(height: 8), + if (!isCustomMapMode) ...[ + const TrailControls(), + const SizedBox(height: 8), + ], FloatingActionButton.small( heroTag: 'layer_selector', onPressed: () => _showLayerSelector(context), diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index bd85437..7de4ff0 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -2020,12 +2020,19 @@ class _MessagesTabState extends State { void _handleMessageTap(Message message) { if (widget.onNavigateToMap == null) return; - if (message.isSarMarker && message.sarGpsCoordinates != null) { + if (message.isSarMarker) { final mapProvider = context.read(); - mapProvider.navigateToLocation( - location: message.sarGpsCoordinates!, - zoom: 15.0, - ); + final marker = message.toSarMarker(); + if (marker == null) { + return; + } + final error = mapProvider.navigateToSarMarker(marker); + if (error != null && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + return; + } widget.onNavigateToMap?.call(); return; } @@ -2034,7 +2041,16 @@ class _MessagesTabState extends State { debugPrint('πŸ—ΊοΈ [MessagesTab] Drawing tapped! ID: ${message.drawingId}'); final mapProvider = context.read(); final drawingProvider = context.read(); - mapProvider.navigateToDrawing(message.drawingId!, drawingProvider); + final error = mapProvider.navigateToDrawing( + message.drawingId!, + drawingProvider, + ); + if (error != null && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + return; + } widget.onNavigateToMap?.call(); } } diff --git a/lib/services/map_marker_service.dart b/lib/services/map_marker_service.dart index 7101110..0f319ab 100644 --- a/lib/services/map_marker_service.dart +++ b/lib/services/map_marker_service.dart @@ -152,10 +152,11 @@ class MapMarkerService { required BuildContext context, Function(SarMarker)? onTap, double mapRotation = 0, + LatLng Function(LatLng point)? pointTransformer, }) { return sarMarkers.map((marker) { return Marker( - point: marker.location, + point: pointTransformer?.call(marker.location) ?? marker.location, width: 90, height: 100, rotate: false, // Don't rotate the entire marker with map diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index 73edeb9..f104ed0 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -346,6 +346,9 @@ class MessageStorageService { 'isSarMarker': message.isSarMarker, 'sarGpsLat': message.sarGpsCoordinates?.latitude, 'sarGpsLon': message.sarGpsCoordinates?.longitude, + 'sarCustomMapLat': message.sarCustomMapPoint?.latitude, + 'sarCustomMapLon': message.sarCustomMapPoint?.longitude, + 'sarCustomMapId': message.sarCustomMapId, 'sarNotes': message.sarNotes, 'sarCustomEmoji': message.sarCustomEmoji, 'sarColorIndex': message.sarColorIndex, @@ -482,8 +485,19 @@ class MessageStorageService { isSarMarker: json['isSarMarker'] as bool? ?? false, sarGpsCoordinates: json['sarGpsLat'] != null && json['sarGpsLon'] != null - ? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double) + ? LatLng( + (json['sarGpsLat'] as num).toDouble(), + (json['sarGpsLon'] as num).toDouble(), + ) : null, + sarCustomMapPoint: + json['sarCustomMapLat'] != null && json['sarCustomMapLon'] != null + ? LatLng( + (json['sarCustomMapLat'] as num).toDouble(), + (json['sarCustomMapLon'] as num).toDouble(), + ) + : null, + sarCustomMapId: json['sarCustomMapId'] as String?, sarNotes: json['sarNotes'] as String?, sarCustomEmoji: json['sarCustomEmoji'] as String?, sarColorIndex: json['sarColorIndex'] as int?, diff --git a/lib/utils/custom_map_id.dart b/lib/utils/custom_map_id.dart new file mode 100644 index 0000000..dfcf40c --- /dev/null +++ b/lib/utils/custom_map_id.dart @@ -0,0 +1,18 @@ +const int customMapKey6HexLength = 12; + +String? normalizeCustomMapId(String? mapId) { + if (mapId == null) { + return null; + } + + final normalized = mapId.trim().toLowerCase(); + if (normalized.isEmpty) { + return null; + } + + if (normalized.length <= customMapKey6HexLength) { + return normalized; + } + + return normalized.substring(0, customMapKey6HexLength); +} diff --git a/lib/utils/drawing_message_parser.dart b/lib/utils/drawing_message_parser.dart index 5697ba4..603dbcc 100644 --- a/lib/utils/drawing_message_parser.dart +++ b/lib/utils/drawing_message_parser.dart @@ -1,19 +1,17 @@ import 'dart:convert'; + +import '../models/map_coordinate_space.dart'; import '../models/map_drawing.dart'; +import 'custom_map_id.dart'; -/// Parser for drawing messages transmitted over mesh network class DrawingMessageParser { - /// Drawing message prefix - static const String prefix = 'D:'; + static const String legacyPrefix = 'D:'; + static const String customMapPrefix = 'D2:'; - /// Check if message is a drawing message static bool isDrawingMessage(String text) { - return text.startsWith(prefix); + return text.startsWith(legacyPrefix) || text.startsWith(customMapPrefix); } - /// Parse drawing message text into MapDrawing object - /// senderName and messageId should be extracted from packet metadata - /// Returns null if parsing fails static MapDrawing? parseDrawingMessage( String text, { String? senderName, @@ -24,126 +22,96 @@ class DrawingMessageParser { } try { - // Remove prefix - final jsonStr = text.substring(prefix.length); + if (text.startsWith(customMapPrefix)) { + final json = + jsonDecode(text.substring(customMapPrefix.length)) + as Map; + final mapId = normalizeCustomMapId(json['m'] as String?); + if (mapId == null || mapId.isEmpty) { + return null; + } + return MapDrawing.fromNetworkJson( + json, + senderName: senderName, + messageId: messageId, + coordinateSpace: MapCoordinateSpace.customMap, + mapId: mapId, + ); + } - // Parse JSON - final json = jsonDecode(jsonStr) as Map; - - // Use ultra-compact network format parser - // Sender name and message ID come from packet metadata, not JSON + final json = + jsonDecode(text.substring(legacyPrefix.length)) + as Map; return MapDrawing.fromNetworkJson( json, senderName: senderName, messageId: messageId, + coordinateSpace: MapCoordinateSpace.geo, ); - } catch (e) { + } catch (_) { return null; } } - /// Create drawing message text from MapDrawing object - /// Sender will be determined from packet metadata on receiving end static String createDrawingMessage(MapDrawing drawing) { final json = drawing.toNetworkJson(); - final jsonStr = jsonEncode(json).toString(); - return '$prefix$jsonStr'; + final prefix = drawing.coordinateSpace == MapCoordinateSpace.customMap + ? customMapPrefix + : legacyPrefix; + return '$prefix${jsonEncode(json)}'; } - /// 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; - } + final metadata = getDrawingMetadata(text); + return metadata?['type'] as String?; } - /// 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; - } + final metadata = getDrawingMetadata(text); + return metadata?['color'] as String?; } - /// 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 json = + jsonDecode( + text.startsWith(customMapPrefix) + ? text.substring(customMapPrefix.length) + : text.substring(legacyPrefix.length), + ) + 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 + case 0: type = 'Line'; final points = json['p'] as List?; pointCount = points != null ? points.length ~/ 2 : null; break; - case 1: // Rectangle + case 1: type = 'Rectangle'; - pointCount = 4; // Rectangles always have 4 corners + pointCount = 4; break; default: return null; } - // Get color name const colorNames = [ - 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Purple', 'Pink', 'Cyan', + 'Red', + 'Blue', + 'Green', + 'Yellow', + 'Orange', + 'Purple', + 'Pink', + 'Cyan', ]; final color = colorIndex >= 0 && colorIndex < colorNames.length ? colorNames[colorIndex] @@ -153,8 +121,12 @@ class DrawingMessageParser { 'type': type, 'color': color, 'pointCount': pointCount, + 'coordinateSpace': text.startsWith(customMapPrefix) + ? MapCoordinateSpace.customMap.name + : MapCoordinateSpace.geo.name, + 'mapId': normalizeCustomMapId(json['m'] as String?), }; - } catch (e) { + } catch (_) { return null; } } diff --git a/lib/utils/sar_message_parser.dart b/lib/utils/sar_message_parser.dart index 718136e..ae3b195 100644 --- a/lib/utils/sar_message_parser.dart +++ b/lib/utils/sar_message_parser.dart @@ -1,216 +1,206 @@ -import 'package:latlong2/latlong.dart'; -import '../models/sar_marker.dart'; -import '../models/message.dart'; +import 'dart:convert'; + +import 'package:latlong2/latlong.dart'; + +import '../models/map_coordinate_space.dart'; +import '../models/message.dart'; +import '../models/sar_marker.dart'; +import 'custom_map_id.dart'; -/// Parser for SAR (Search & Rescue) special messages -/// Old format: `S::,:` -/// New format: `S:::,:` -/// Examples: -/// S:πŸ§‘:37.7749,-122.4194 (old format) -/// S:πŸ§‘:2:37.7749,-122.4194 (new format with green color) -/// S:πŸ”₯:0:40.7128,-74.0060:Large wildfire spreading (new format with red color) class SarMessageParser { - // Regex for new format with color index: S:emoji:colorIndex:lat,lon:notes - // Captures: emoji, colorIndex (single digit), latitude, longitude, optional message + static const String legacyPrefix = 'S:'; + static const String customMapPrefix = 'S2:'; + static final RegExp _sarPatternNew = RegExp( r'^S:([^:]+):(\d):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)', multiLine: false, ); - // Regex for old format (backward compatibility): S:emoji:lat,lon:notes - // Captures: emoji, latitude, longitude, optional message static final RegExp _sarPatternOld = RegExp( r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)', multiLine: false, ); - /// Check if a message is a SAR marker message static bool isSarMessage(String text) { - // Extract just the first line for matching - final firstLine = text.trim().split('\n').first; - return firstLine.startsWith('S:') && - (_sarPatternNew.hasMatch(firstLine) || - _sarPatternOld.hasMatch(firstLine)); + final lines = text.trim().split('\n'); + final firstLine = lines.isEmpty ? text.trim() : lines.first; + return firstLine.startsWith(legacyPrefix) || + firstLine.startsWith(customMapPrefix); } - /// Parse a SAR message and extract marker information - /// Returns null if the message is not a valid SAR message - /// Supports both old format (S:emoji:lat,lon:notes) and new format (S:emoji:colorIndex:lat,lon:notes) static SarMarkerInfo? parse(String text) { final trimmed = text.trim(); - if (!trimmed.startsWith('S:')) return null; - - // Extract first line (actual SAR marker) - final firstLine = trimmed.split('\n').first; - - // Try new format first (with color index) - var match = _sarPatternNew.firstMatch(firstLine); - bool isNewFormat = match != null; - - // If new format didn't match, try old format - if (match == null) { - match = _sarPatternOld.firstMatch(firstLine); - if (match == null) return null; + if (trimmed.startsWith(customMapPrefix)) { + return _parseCustomMap(trimmed); + } + if (!trimmed.startsWith(legacyPrefix)) { + return null; } + final firstLine = trimmed.split('\n').first; + var match = _sarPatternNew.firstMatch(firstLine); + final isNewFormat = match != null; + match ??= _sarPatternOld.firstMatch(firstLine); + if (match == null) return null; + try { - String emoji; - double latitude; - double longitude; - String? inlineMessage; - int? colorIndex; - - if (isNewFormat) { - // New format: S:emoji:colorIndex:lat,lon:notes - emoji = match.group(1)!; - colorIndex = int.parse(match.group(2)!); - latitude = double.parse(match.group(3)!); - longitude = double.parse(match.group(4)!); - inlineMessage = match.group(5)?.trim(); - } else { - // Old format: S:emoji:lat,lon:notes - emoji = match.group(1)!; - colorIndex = null; // No color index in old format - latitude = double.parse(match.group(2)!); - longitude = double.parse(match.group(3)!); - inlineMessage = match.group(4)?.trim(); - } - - // Validate coordinates + final emoji = match.group(1)!; + final colorIndex = isNewFormat ? int.parse(match.group(2)!) : null; + final latitude = double.parse(match.group(isNewFormat ? 3 : 2)!); + final longitude = double.parse(match.group(isNewFormat ? 4 : 3)!); + final inlineMessage = match.group(isNewFormat ? 5 : 4)?.trim(); if (latitude < -90 || latitude > 90) return null; if (longitude < -180 || longitude > 180) return null; - // Validate color index if present - if (colorIndex != null && (colorIndex < 0 || colorIndex > 7)) { - colorIndex = null; // Invalid index, ignore it - } - - final markerType = SarMarkerType.fromEmoji(emoji); - final location = LatLng(latitude, longitude); - - // Combine inline message with multi-line notes + final additionalNotes = extractNotes(text); String? notes; if (inlineMessage != null && inlineMessage.isNotEmpty) { notes = inlineMessage; } - - // Check for multi-line notes (lines after the first line) - final additionalNotes = extractNotes(text); - if (additionalNotes != null) { + if (additionalNotes != null && additionalNotes.isNotEmpty) { notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes; } return SarMarkerInfo( - type: markerType, - location: location, + type: SarMarkerType.fromEmoji(emoji), + location: LatLng(latitude, longitude), emoji: emoji, notes: notes, - colorIndex: colorIndex, + colorIndex: colorIndex != null && colorIndex >= 0 && colorIndex <= 7 + ? colorIndex + : null, + coordinateSpace: MapCoordinateSpace.geo, ); - } catch (e) { + } catch (_) { + return null; + } + } + + static SarMarkerInfo? _parseCustomMap(String text) { + try { + final json = + jsonDecode(text.substring(customMapPrefix.length)) + as Map; + final emoji = json['e']; + final mapId = normalizeCustomMapId(json['m'] as String?); + final rawPoint = json['p']; + if (emoji is! String || mapId == null || rawPoint is! List) { + return null; + } + final point = rawPoint.cast(); + if (point.length != 2) return null; + final colorIndex = json['c']; + final notes = json['n']; + return SarMarkerInfo( + type: SarMarkerType.fromEmoji(emoji), + location: LatLng(point[0].toDouble(), point[1].toDouble()), + emoji: emoji, + notes: notes is String && notes.isNotEmpty ? notes : null, + colorIndex: colorIndex is int ? colorIndex : null, + coordinateSpace: MapCoordinateSpace.customMap, + mapId: mapId, + ); + } catch (_) { return null; } } - /// Enhance a Message with SAR marker information static Message enhanceMessage(Message message) { final sarInfo = parse(message.text); if (sarInfo == null) return message; return message.copyWith( isSarMarker: true, - sarGpsCoordinates: sarInfo.location, - sarNotes: sarInfo.notes, // Extract and store notes - sarCustomEmoji: sarInfo.emoji, // Always store emoji for type inference - sarColorIndex: sarInfo.colorIndex, // Store color index + sarGpsCoordinates: sarInfo.coordinateSpace == MapCoordinateSpace.geo + ? sarInfo.location + : null, + sarCustomMapPoint: sarInfo.coordinateSpace == MapCoordinateSpace.customMap + ? sarInfo.location + : null, + sarCustomMapId: sarInfo.mapId, + sarNotes: sarInfo.notes, + sarCustomEmoji: sarInfo.emoji, + sarColorIndex: sarInfo.colorIndex, ); } - /// Create a SAR marker message text (new format with color index) static String createSarMessage({ required SarMarkerType type, required LatLng location, String? notes, int? colorIndex, }) { - // New format: S:emoji:colorIndex:lat,lon:notes - final colorIdx = colorIndex ?? 0; // Default to red if not specified + final colorIdx = colorIndex ?? 0; final text = - 'S:${type.emoji}:$colorIdx:${location.latitude.toString()},${location.longitude.toString()}'; + 'S:${type.emoji}:$colorIdx:${location.latitude},${location.longitude}'; if (notes != null && notes.isNotEmpty) { - // Use colon-separated format for inline message return '$text:$notes'; } return text; } - /// Extract additional notes from SAR message (text after the marker) + static String createCustomMapSarMessage({ + required String emoji, + required String mapId, + required LatLng point, + String? notes, + int? colorIndex, + }) { + final payload = { + 'e': emoji, + 'c': colorIndex ?? 0, + 'm': normalizeCustomMapId(mapId), + 'p': [point.latitude.round(), point.longitude.round()], + }; + if (notes != null && notes.isNotEmpty) { + payload['n'] = notes; + } + return '$customMapPrefix${jsonEncode(payload)}'; + } + static String? extractNotes(String text) { final trimmed = text.trim(); final lines = trimmed.split('\n'); if (lines.length <= 1) return null; - - // Everything after the first line is considered notes return lines.sublist(1).join('\n').trim(); } - /// Validate SAR message format static bool isValidFormat(String text) { return isSarMessage(text) && parse(text) != null; } - /// Get a user-friendly error message for invalid SAR format static String? getFormatError(String text) { - if (!text.trim().startsWith('S:')) { + final trimmed = text.trim(); + if (trimmed.startsWith(customMapPrefix)) { + return parse(text) == null + ? 'Invalid format for custom map SAR marker' + : null; + } + if (!trimmed.startsWith(legacyPrefix)) { return 'SAR message must start with "S:"'; } - final parts = text.trim().split(':'); - if (parts.length < 3) { - return 'Invalid format. Use: S::,'; + final firstLine = trimmed.split('\n').first; + if (firstLine == legacyPrefix) { + return 'Invalid format for SAR marker'; + } + final parts = firstLine.split(':'); + if (parts.length < 2 || parts[1].isEmpty) { + return 'Missing emoji'; } - final emoji = parts[1]; - if (emoji.isEmpty) { - return 'Missing emoji marker (πŸ§‘, πŸ”₯, or πŸ•οΈ)'; - } - - final coords = parts[2]; - if (!coords.contains(',')) { - return 'Coordinates must be separated by comma'; - } - - final coordParts = coords.split(','); - if (coordParts.length != 2) { - return 'Invalid coordinates format'; - } - - try { - final lat = double.parse(coordParts[0]); - final lon = double.parse(coordParts[1]); - - if (lat < -90 || lat > 90) { - return 'Latitude must be between -90 and 90'; - } - if (lon < -180 || lon > 180) { - return 'Longitude must be between -180 and 180'; - } - } catch (e) { - return 'Invalid coordinate values'; - } - - return null; + return parse(text) == null ? 'Invalid format for SAR marker' : null; } } -/// Parsed SAR marker information class SarMarkerInfo { final SarMarkerType type; final LatLng location; final String emoji; final String? notes; - final int? - colorIndex; // Color index from standard palette (0-7), null for backward compatibility + final int? colorIndex; + final MapCoordinateSpace coordinateSpace; + final String? mapId; SarMarkerInfo({ required this.type, @@ -218,10 +208,12 @@ class SarMarkerInfo { required this.emoji, this.notes, this.colorIndex, + required this.coordinateSpace, + this.mapId, }); @override String toString() { - return 'SarMarkerInfo(type: ${type.displayName}, location: $location, colorIndex: $colorIndex, notes: $notes)'; + return 'SarMarkerInfo(type: ${type.displayName}, location: $location, colorIndex: $colorIndex, space: ${coordinateSpace.name}, mapId: $mapId)'; } } diff --git a/lib/widgets/drawing_minimap_preview.dart b/lib/widgets/drawing_minimap_preview.dart index 7cda08a..e418beb 100644 --- a/lib/widgets/drawing_minimap_preview.dart +++ b/lib/widgets/drawing_minimap_preview.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:latlong2/latlong.dart'; import '../models/map_drawing.dart'; +import '../models/map_coordinate_space.dart'; /// Minimap preview widget for map drawings /// Renders a small 80x80px preview of a drawing on a map background @@ -52,8 +53,12 @@ class DrawingMinimapPreview extends StatelessWidget { 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 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; @@ -79,6 +84,12 @@ class DrawingMinimapPreview extends StatelessWidget { @override Widget build(BuildContext context) { final bounds = _calculateBounds(); + final isCustomMap = drawing.coordinateSpace == MapCoordinateSpace.customMap; + final drawingPoints = drawing is LineDrawing + ? (drawing as LineDrawing).points + : drawing is RectangleDrawing + ? (drawing as RectangleDrawing).corners + : const []; return Container( width: 80, @@ -86,26 +97,27 @@ class DrawingMinimapPreview extends StatelessWidget { decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.grey.shade400, - width: 1, - ), + border: Border.all(color: Colors.grey.shade400, width: 1), ), child: ClipRRect( borderRadius: BorderRadius.circular(7), child: flutter_map.FlutterMap( options: flutter_map.MapOptions( + crs: isCustomMap + ? const flutter_map.CrsSimple() + : const flutter_map.Epsg3857(), initialCameraFit: flutter_map.CameraFit.bounds( bounds: bounds, padding: const EdgeInsets.all(8), ), interactionOptions: const flutter_map.InteractionOptions( - flags: flutter_map.InteractiveFlag.none, // Disable all interactions + flags: + flutter_map.InteractiveFlag.none, // Disable all interactions ), ), children: [ // Use provided tile layer or fallback to gray background - if (tileLayer != null) + if (!isCustomMap && tileLayer != null) tileLayer! else Container(color: Colors.grey.shade300), @@ -115,7 +127,7 @@ class DrawingMinimapPreview extends StatelessWidget { flutter_map.PolylineLayer( polylines: [ flutter_map.Polyline( - points: (drawing as LineDrawing).points, + points: drawingPoints, strokeWidth: 3.0, color: drawing.color, ), @@ -125,7 +137,7 @@ class DrawingMinimapPreview extends StatelessWidget { flutter_map.PolygonLayer( polygons: [ flutter_map.Polygon( - points: (drawing as RectangleDrawing).corners, + points: drawingPoints, color: drawing.color.withValues(alpha: 0.3), borderColor: drawing.color, borderStrokeWidth: 3.0, diff --git a/lib/widgets/map/drawing_layer.dart b/lib/widgets/map/drawing_layer.dart index fa2cd27..8a92f08 100644 --- a/lib/widgets/map/drawing_layer.dart +++ b/lib/widgets/map/drawing_layer.dart @@ -8,8 +8,14 @@ import '../../l10n/app_localizations.dart'; class DrawingLayer extends StatelessWidget { final List drawings; final MapDrawing? previewDrawing; + final LatLng Function(LatLng point)? pointTransformer; - const DrawingLayer({super.key, required this.drawings, this.previewDrawing}); + const DrawingLayer({ + super.key, + required this.drawings, + this.previewDrawing, + this.pointTransformer, + }); @override Widget build(BuildContext context) { @@ -66,12 +72,16 @@ class DrawingLayer extends StatelessWidget { /// Get points from a drawing based on its type List _getPoints(MapDrawing drawing) { if (drawing is LineDrawing) { - return drawing.points; + return drawing.points.map(_transformPoint).toList(); } else if (drawing is RectangleDrawing) { - return drawing.corners; + return drawing.corners.map(_transformPoint).toList(); } return []; } + + LatLng _transformPoint(LatLng point) { + return pointTransformer?.call(point) ?? point; + } } /// Widget that shows drawing markers (start/end points) @@ -80,6 +90,7 @@ class DrawingMarkersLayer extends StatelessWidget { final Function(String drawingId)? onDeleteDrawing; final Function(MapDrawing drawing)? onTapDrawing; final bool showDeleteButtons; + final LatLng Function(LatLng point)? pointTransformer; const DrawingMarkersLayer({ super.key, @@ -87,6 +98,7 @@ class DrawingMarkersLayer extends StatelessWidget { this.onDeleteDrawing, this.onTapDrawing, this.showDeleteButtons = false, + this.pointTransformer, }); @override @@ -144,17 +156,23 @@ class DrawingMarkersLayer extends StatelessWidget { if (drawing is LineDrawing && drawing.points.isNotEmpty) { // Use the middle point of the line final midIndex = drawing.points.length ~/ 2; - return drawing.points[midIndex]; + return _transformPoint(drawing.points[midIndex]); } else if (drawing is RectangleDrawing) { // Use the center of the rectangle - return LatLng( - (drawing.topLeft.latitude + drawing.bottomRight.latitude) / 2, - (drawing.topLeft.longitude + drawing.bottomRight.longitude) / 2, + return _transformPoint( + LatLng( + (drawing.topLeft.latitude + drawing.bottomRight.latitude) / 2, + (drawing.topLeft.longitude + drawing.bottomRight.longitude) / 2, + ), ); } return null; } + LatLng _transformPoint(LatLng point) { + return pointTransformer?.call(point) ?? point; + } + /// Show delete confirmation dialog void _showDeleteDialog(BuildContext context, MapDrawing drawing) { showDialog( diff --git a/lib/widgets/messages/custom_map_sar_update_sheet.dart b/lib/widgets/messages/custom_map_sar_update_sheet.dart new file mode 100644 index 0000000..46eddc6 --- /dev/null +++ b/lib/widgets/messages/custom_map_sar_update_sheet.dart @@ -0,0 +1,462 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; +import '../../models/sar_template.dart'; +import '../../providers/contacts_provider.dart'; +import '../../services/sar_template_service.dart'; +import 'sar_update_sheet.dart' show TemplateChip; + +class CustomMapSarUpdateSheet extends StatefulWidget { + final String mapName; + final String mapId; + final String pointLabel; + final Future Function( + String emoji, + String name, + Uint8List? roomPublicKey, + bool sendToChannel, + bool sendToAllContacts, + int colorIndex, + ) + onSend; + + const CustomMapSarUpdateSheet({ + super.key, + required this.mapName, + required this.mapId, + required this.pointLabel, + required this.onSend, + }); + + @override + State createState() => + _CustomMapSarUpdateSheetState(); +} + +class _CustomMapSarUpdateSheetState extends State { + final SarTemplateService _templateService = SarTemplateService(); + final TextEditingController _notesController = TextEditingController(); + + List _templates = []; + SarTemplate? _selectedTemplate; + Contact? _selectedContact; + bool _sendToAllContacts = false; + + @override + void initState() { + super.initState(); + _initializeTemplates(); + _setDefaultDestination(); + } + + Future _initializeTemplates() async { + if (!_templateService.isInitialized) { + await _templateService.initialize(); + } + if (!mounted) return; + setState(() { + _templates = _templateService.templates; + if (_templates.isNotEmpty) { + _selectedTemplate = _templates.first; + } + }); + } + + void _setDefaultDestination() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final contactsProvider = context.read(); + final roomsAndChannels = contactsProvider.roomsAndChannels; + final teamContacts = contactsProvider.chatContacts; + + if (teamContacts.length > 1) { + setState(() { + _sendToAllContacts = true; + _selectedContact = null; + }); + return; + } + + if (teamContacts.length == 1) { + setState(() { + _sendToAllContacts = false; + _selectedContact = teamContacts.first; + }); + return; + } + + if (roomsAndChannels.any((c) => c.isRoom)) { + setState(() { + _sendToAllContacts = false; + _selectedContact = roomsAndChannels.firstWhere((c) => c.isRoom); + }); + return; + } + + if (roomsAndChannels.isNotEmpty) { + setState(() { + _sendToAllContacts = false; + _selectedContact = roomsAndChannels.first; + }); + } + }); + } + + @override + void dispose() { + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + final bottomSafeArea = MediaQuery.of(context).padding.bottom; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return AnimatedPadding( + padding: EdgeInsets.only(bottom: keyboardHeight), + duration: const Duration(milliseconds: 100), + child: Container( + height: MediaQuery.of(context).size.height * 0.88, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + children: [ + Text( + 'Send SAR marker', + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Custom cave map point', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(width: 48), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 80 + bottomSafeArea, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.markerType, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ..._templates.map((template) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TemplateChip( + template: template, + isSelected: _selectedTemplate?.id == template.id, + onTap: () => + setState(() => _selectedTemplate = template), + ), + ); + }), + const SizedBox(height: 24), + Text( + AppLocalizations.of(context)!.sendTo, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Consumer( + builder: (context, contactsProvider, child) { + final teamContacts = contactsProvider.chatContacts; + final roomsAndChannels = + contactsProvider.roomsAndChannels; + final destinations = [ + ...teamContacts, + ...roomsAndChannels.where((c) => c.isRoom), + ...roomsAndChannels.where((c) => c.isChannel), + ]; + + if (destinations.isEmpty && teamContacts.isEmpty) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + ), + ), + child: const Text('No destinations available'), + ); + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _sendToAllContacts + ? 'all_contacts' + : _selectedContact?.publicKeyHex, + hint: Text( + AppLocalizations.of(context)!.selectDestination, + ), + dropdownColor: + colorScheme.surfaceContainerHighest, + isExpanded: true, + items: [ + if (teamContacts.isNotEmpty) + DropdownMenuItem( + value: 'all_contacts', + child: Row( + children: [ + const Icon(Icons.group, size: 18), + const SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of( + context, + )!.allTeamContacts, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ...destinations.map((contact) { + final icon = contact.isChat + ? Icons.person + : contact.isRoom + ? Icons.storage + : Icons.public; + return DropdownMenuItem( + value: contact.publicKeyHex, + child: Row( + children: [ + Icon(icon, size: 18), + const SizedBox(width: 12), + Expanded( + child: Text( + contact.getLocalizedDisplayName( + context, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }), + ], + onChanged: (value) { + setState(() { + if (value == 'all_contacts') { + _sendToAllContacts = true; + _selectedContact = null; + } else { + _sendToAllContacts = false; + _selectedContact = destinations.firstWhere( + (c) => c.publicKeyHex == value, + ); + } + }); + }, + ), + ), + ); + }, + ), + const SizedBox(height: 24), + Text( + 'Map point', + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.map_outlined, + size: 20, + color: Colors.blue, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.mapName, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + widget.pointLabel, + style: TextStyle( + color: colorScheme.onSurface, + fontFamily: 'monospace', + fontSize: 13, + ), + ), + const SizedBox(height: 4), + Text( + 'Map ID: ${widget.mapId}', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + 'Additional notes (optional)', + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Add additional details', + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.all(16), + ), + ), + ], + ), + ), + ), + Container( + padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomSafeArea), + decoration: BoxDecoration( + color: colorScheme.surface, + border: Border( + top: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.2), + ), + ), + ), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () async { + if (_selectedTemplate == null || + (!_sendToAllContacts && _selectedContact == null)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Select marker type and destination'), + ), + ); + return; + } + + await widget.onSend( + _selectedTemplate!.emoji, + _notesController.text.trim().isEmpty + ? _selectedTemplate!.name + : _notesController.text.trim(), + _selectedContact?.isChannel == true + ? null + : _selectedContact?.publicKey, + _selectedContact?.isChannel == true, + _sendToAllContacts, + _templates.indexOf(_selectedTemplate!), + ); + + if (context.mounted) { + Navigator.pop(context); + } + }, + icon: const Icon(Icons.send), + label: const Text('Send'), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index ee613e3..fcdb112 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:latlong2/latlong.dart'; import 'package:share_plus/share_plus.dart'; import 'package:provider/provider.dart'; import '../../models/contact.dart'; @@ -7,6 +8,7 @@ import '../../models/message.dart'; import '../../models/sar_marker.dart'; import '../../models/sar_template.dart'; import '../../models/map_drawing.dart'; +import '../../models/map_coordinate_space.dart'; import '../../providers/messages_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/connection_provider.dart'; @@ -1490,7 +1492,7 @@ class _MessageBubbleState extends State { void _navigateToDrawing(BuildContext context) { if (widget.message.drawingId == null) return; - widget.onNavigateToMap?.call(); + widget.onTap?.call(); } void _copyDrawingCoordinates(BuildContext context) { @@ -1505,21 +1507,18 @@ class _MessageBubbleState extends State { } // Format coordinates based on drawing type + String formatPoint(LatLng point) { + if (drawing.coordinateSpace == MapCoordinateSpace.customMap) { + return '${point.latitude.toStringAsFixed(0)}, ${point.longitude.toStringAsFixed(0)}'; + } + return '${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}'; + } + String coordinatesText; if (drawing is LineDrawing) { - coordinatesText = drawing.points - .map( - (p) => - '${p.latitude.toStringAsFixed(5)}, ${p.longitude.toStringAsFixed(5)}', - ) - .join('\n'); + coordinatesText = drawing.points.map(formatPoint).join('\n'); } else if (drawing is RectangleDrawing) { - coordinatesText = drawing.corners - .map( - (p) => - '${p.latitude.toStringAsFixed(5)}, ${p.longitude.toStringAsFixed(5)}', - ) - .join('\n'); + coordinatesText = drawing.corners.map(formatPoint).join('\n'); } else { ToastLogger.error(context, 'Unknown drawing type'); return; @@ -2294,6 +2293,72 @@ class _MessageBubbleState extends State { ], ), ), + ] else if (message.sarCustomMapPoint != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: isDarkMode ? 0.18 : 0.05, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.map_outlined, + size: 15, + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Custom map marker', + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Point: ${message.sarCustomMapPoint!.latitude.toStringAsFixed(0)}, ${message.sarCustomMapPoint!.longitude.toStringAsFixed(0)}', + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + ), + ), + if (message.sarCustomMapId != null && + message.sarCustomMapId!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + 'Map ID: ${message.sarCustomMapId}', + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.15, + ), + ), + ], + ], + ), + ), ], ], ), diff --git a/pubspec.lock b/pubspec.lock index f261013..729e760 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -786,11 +786,9 @@ packages: meshcore_client: dependency: "direct main" description: - path: "." - ref: f600789fac7f743b1c7db8aa24e441405413f669 - resolved-ref: f600789fac7f743b1c7db8aa24e441405413f669 - url: "https://github.com/dz0ny/meshcore_client.git" - source: git + path: "../meshcore_client" + relative: true + source: path version: "0.1.0" meta: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index bfad7d0..6258c79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -135,6 +135,8 @@ dev_dependencies: fake_async: ^1.3.3 dependency_overrides: + meshcore_client: + path: ../meshcore_client # path_provider_foundation 2.6.0 pulls in package:objective_c as a native # asset. That framework has been ending up archived with a macOS platform # slice and fails App Store validation for iOS uploads. diff --git a/test/utils/drawing_message_parser_test.dart b/test/utils/drawing_message_parser_test.dart index f1e3c49..30125b6 100644 --- a/test/utils/drawing_message_parser_test.dart +++ b/test/utils/drawing_message_parser_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_sar_app/models/map_drawing.dart'; +import 'package:meshcore_sar_app/models/map_coordinate_space.dart'; import 'package:meshcore_sar_app/utils/drawing_message_parser.dart'; void main() { @@ -10,10 +11,7 @@ void main() { id: 'test-123', color: DrawingColors.palette[0], createdAt: DateTime.now(), - points: [ - LatLng(37.7749, -122.4194), - LatLng(37.7750, -122.4195), - ], + points: [LatLng(37.7749, -122.4194), LatLng(37.7750, -122.4195)], ); final message = DrawingMessageParser.createDrawingMessage(drawing); @@ -59,29 +57,55 @@ void main() { expect((parsed as LineDrawing).points.length, equals(3)); }); - test('createDrawingMessage handles rectangle with proper string format', () { - final drawing = RectangleDrawing( - id: 'rect-789', - color: DrawingColors.palette[4], // orange + test( + 'createDrawingMessage handles rectangle with proper string format', + () { + final drawing = RectangleDrawing( + id: 'rect-789', + color: DrawingColors.palette[4], // orange + createdAt: DateTime.now(), + topLeft: LatLng(45.5231, -122.6765), + bottomRight: LatLng(45.5100, -122.6600), + ); + + final message = DrawingMessageParser.createDrawingMessage(drawing); + + // CRITICAL: Must be pure string + expect(message, isA()); + expect(message, startsWith('D:')); + + // Should contain compact JSON format + expect(message, contains('"t":')); + expect(message, contains('"c":')); + expect(message, contains('"b":')); + + // Must NOT contain any object representations + expect(message, isNot(contains('RectangleDrawing'))); + expect(message, isNot(contains('Instance'))); + }, + ); + + test('custom-map drawings use D2 format and preserve map metadata', () { + final drawing = LineDrawing( + id: 'custom-1', + color: DrawingColors.palette[1], createdAt: DateTime.now(), - topLeft: LatLng(45.5231, -122.6765), - bottomRight: LatLng(45.5100, -122.6600), + points: [LatLng(120, 200), LatLng(320, 450)], + coordinateSpace: MapCoordinateSpace.customMap, + mapId: '1234567890abcdef', ); final message = DrawingMessageParser.createDrawingMessage(drawing); - // CRITICAL: Must be pure string - expect(message, isA()); - expect(message, startsWith('D:')); + expect(message, startsWith('D2:')); - // Should contain compact JSON format - expect(message, contains('"t":')); - expect(message, contains('"c":')); - expect(message, contains('"b":')); - - // Must NOT contain any object representations - expect(message, isNot(contains('RectangleDrawing'))); - expect(message, isNot(contains('Instance'))); + final parsed = DrawingMessageParser.parseDrawingMessage(message); + expect(parsed, isA()); + expect(parsed!.coordinateSpace, MapCoordinateSpace.customMap); + expect(parsed.mapId, '1234567890ab'); + final parsedLine = parsed as LineDrawing; + expect(parsedLine.points.first.latitude, 120); + expect(parsedLine.points.first.longitude, 200); }); test('JSON encoding produces string with coordinates as numbers', () { @@ -89,9 +113,7 @@ void main() { id: 'coord-test', color: DrawingColors.palette[1], // blue createdAt: DateTime.now(), - points: [ - LatLng(37.77490, -122.41940), - ], + points: [LatLng(37.77490, -122.41940)], ); final message = DrawingMessageParser.createDrawingMessage(drawing); @@ -110,17 +132,17 @@ void main() { }); test('isDrawingMessage correctly identifies valid drawing messages', () { - expect(DrawingMessageParser.isDrawingMessage('D:{"t":0,"c":1,"p":[1,2]}'), isTrue); + expect( + DrawingMessageParser.isDrawingMessage('D:{"t":0,"c":1,"p":[1,2]}'), + isTrue, + ); expect(DrawingMessageParser.isDrawingMessage('S:πŸ§‘:37,-122'), isFalse); expect(DrawingMessageParser.isDrawingMessage('Plain text'), isFalse); expect(DrawingMessageParser.isDrawingMessage('D:'), isTrue); }); test('parseDrawingMessage returns null for malformed messages', () { - expect( - DrawingMessageParser.parseDrawingMessage('Not a drawing'), - isNull, - ); + expect(DrawingMessageParser.parseDrawingMessage('Not a drawing'), isNull); expect( DrawingMessageParser.parseDrawingMessage('D:invalid json'), isNull, @@ -132,10 +154,7 @@ void main() { id: 'roundtrip-1', color: DrawingColors.palette[3], // yellow createdAt: DateTime.now(), - points: [ - LatLng(51.5074, -0.1278), - LatLng(51.5075, -0.1279), - ], + points: [LatLng(51.5074, -0.1278), LatLng(51.5075, -0.1279)], ); // Create message @@ -204,9 +223,7 @@ void main() { id: 'precision-test', color: DrawingColors.palette[0], createdAt: DateTime.now(), - points: [ - LatLng(37.774901234567, -122.419401234567), - ], + points: [LatLng(37.774901234567, -122.419401234567)], ); final message = DrawingMessageParser.createDrawingMessage(drawing); @@ -259,8 +276,14 @@ void main() { final lineMsg = 'D:{"t":0,"c":1,"p":[1,2,3,4]}'; final rectMsg = 'D:{"t":1,"c":2,"b":[1,2,3,4]}'; - expect(DrawingMessageParser.getDrawingTypeDisplay(lineMsg), equals('Line')); - expect(DrawingMessageParser.getDrawingTypeDisplay(rectMsg), equals('Rectangle')); + expect( + DrawingMessageParser.getDrawingTypeDisplay(lineMsg), + equals('Line'), + ); + expect( + DrawingMessageParser.getDrawingTypeDisplay(rectMsg), + equals('Rectangle'), + ); expect(DrawingMessageParser.getDrawingTypeDisplay('Invalid'), isNull); }); @@ -322,7 +345,10 @@ void main() { expect(message, isA()); expect(message, startsWith('D:')); // Should still be parseable - expect(() => DrawingMessageParser.parseDrawingMessage(message), returnsNormally); + expect( + () => DrawingMessageParser.parseDrawingMessage(message), + returnsNormally, + ); }); }); } diff --git a/test/utils/sar_message_parser_test.dart b/test/utils/sar_message_parser_test.dart index 4488323..32dcb29 100644 --- a/test/utils/sar_message_parser_test.dart +++ b/test/utils/sar_message_parser_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_sar_app/models/sar_marker.dart'; +import 'package:meshcore_sar_app/models/map_coordinate_space.dart'; import 'package:meshcore_sar_app/utils/sar_message_parser.dart'; void main() { @@ -50,7 +51,10 @@ void main() { // CRITICAL: Coordinates must be in string form, not Object expect(message, contains('40.7128')); - expect(message, contains('-74.006')); // Trailing zero trimmed by toString() + expect( + message, + contains('-74.006'), + ); // Trailing zero trimmed by toString() // Must NOT contain object representation expect(message, isNot(contains('LatLng'))); @@ -115,6 +119,26 @@ void main() { expect(info.notes, equals('Large fire')); }); + test('custom-map SAR markers use S2 format and preserve map metadata', () { + final message = SarMessageParser.createCustomMapSarMessage( + emoji: 'πŸ“¦', + mapId: 'abcdef1234567890', + point: LatLng(250, 400), + notes: 'Cache location', + colorIndex: 4, + ); + + expect(message, startsWith('S2:')); + + final parsed = SarMessageParser.parse(message); + expect(parsed, isNotNull); + expect(parsed!.coordinateSpace, MapCoordinateSpace.customMap); + expect(parsed.mapId, 'abcdef123456'); + expect(parsed.location.latitude, 250); + expect(parsed.location.longitude, 400); + expect(parsed.notes, 'Cache location'); + }); + test('round-trip: create -> parse -> create preserves format', () { final original = SarMessageParser.createSarMessage( type: SarMarkerType.stagingArea, @@ -271,15 +295,9 @@ void main() { isTrue, ); - expect( - SarMessageParser.isValidFormat('S:invalid:format'), - isFalse, - ); + expect(SarMessageParser.isValidFormat('S:invalid:format'), isFalse); - expect( - SarMessageParser.isValidFormat('Not SAR message'), - isFalse, - ); + expect(SarMessageParser.isValidFormat('Not SAR message'), isFalse); }); test('getFormatError provides helpful error messages', () { @@ -288,10 +306,7 @@ void main() { contains('must start with "S:"'), ); - expect( - SarMessageParser.getFormatError('S:'), - contains('Invalid format'), - ); + expect(SarMessageParser.getFormatError('S:'), contains('Invalid format')); expect( SarMessageParser.getFormatError('S::37.7,-122.4'), @@ -415,6 +430,7 @@ void main() { emoji: 'πŸ§‘', notes: 'Test notes', colorIndex: 2, + coordinateSpace: MapCoordinateSpace.geo, ); final str = info.toString();