Add custom cave map mode

This commit is contained in:
Janez T
2026-03-16 10:50:44 +01:00
parent c82e0c3452
commit fedd7b9a2b
23 changed files with 2948 additions and 1058 deletions

View File

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

View File

@@ -0,0 +1,11 @@
enum MapCoordinateSpace {
geo,
customMap;
static MapCoordinateSpace fromName(String? value) {
return MapCoordinateSpace.values.firstWhere(
(space) => space.name == value,
orElse: () => MapCoordinateSpace.geo,
);
}
}

View File

@@ -2,35 +2,26 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'map_coordinate_space.dart';
import '../utils/custom_map_id.dart';
/// Drawing shape type /// Drawing shape type
enum DrawingShapeType { enum DrawingShapeType { line, rectangle }
line,
rectangle,
}
/// Drawing color enum for compact network transmission /// Drawing color enum for compact network transmission
enum DrawingColor { enum DrawingColor { red, blue, green, yellow, orange, purple, pink, cyan }
red, // 0
blue, // 1
green, // 2
yellow, // 3
orange, // 4
purple, // 5
pink, // 6
cyan, // 7
}
/// Drawing colors available for user selection /// Drawing colors available for user selection
class DrawingColors { class DrawingColors {
static const List<Color> palette = [ static const List<Color> palette = [
Colors.red, // index 0 Colors.red,
Colors.blue, // index 1 Colors.blue,
Colors.green, // index 2 Colors.green,
Colors.yellow, // index 3 Colors.yellow,
Colors.orange, // index 4 Colors.orange,
Colors.purple, // index 5 Colors.purple,
Colors.pink, // index 6 Colors.pink,
Colors.cyan, // index 7 Colors.cyan,
]; ];
static String colorToName(Color color) { static String colorToName(Color color) {
@@ -45,37 +36,36 @@ class DrawingColors {
return 'Unknown'; return 'Unknown';
} }
/// Convert Color to enum index for network transmission
static int colorToIndex(Color color) { static int colorToIndex(Color color) {
for (int i = 0; i < palette.length; i++) { for (int i = 0; i < palette.length; i++) {
if (palette[i].toARGB32() == color.toARGB32()) { if (palette[i].toARGB32() == color.toARGB32()) {
return i; 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) { static Color indexToColor(int index) {
if (index >= 0 && index < palette.length) { if (index >= 0 && index < palette.length) {
return palette[index]; return palette[index];
} }
return palette[0]; // Default to red if invalid index return palette[0];
} }
} }
/// Base class for map drawings
abstract class MapDrawing { abstract class MapDrawing {
final String id; final String id;
final DrawingShapeType type; final DrawingShapeType type;
final Color color; final Color color;
final DateTime createdAt; final DateTime createdAt;
final String? senderName; // Name of sender (null if local drawing) final String? senderName;
final bool isReceived; // True if drawing was received from another node final bool isReceived;
final String? messageId; // ID of the source message (for navigation) final String? messageId;
final bool isShared; // Whether drawing has been broadcast over mesh final bool isShared;
final bool isSent; // Whether this is a sent drawing (vs received) final bool isSent;
final bool isHidden; // Temporary visibility toggle (session only, not persisted) final bool isHidden;
final MapCoordinateSpace coordinateSpace;
final String? mapId;
MapDrawing({ MapDrawing({
required this.id, required this.id,
@@ -88,79 +78,79 @@ abstract class MapDrawing {
this.isShared = false, this.isShared = false,
this.isSent = false, this.isSent = false,
this.isHidden = false, this.isHidden = false,
this.coordinateSpace = MapCoordinateSpace.geo,
this.mapId,
}); });
/// Convert to JSON for persistence bool get isCustomMap => coordinateSpace == MapCoordinateSpace.customMap;
Map<String, dynamic> toJson(); Map<String, dynamic> 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<String, dynamic> toNetworkJson(); Map<String, dynamic> toNetworkJson();
/// Parse network JSON (compact format)
/// senderName and messageId will be populated from packet metadata
static MapDrawing? fromNetworkJson( static MapDrawing? fromNetworkJson(
Map<String, dynamic> json, { Map<String, dynamic> json, {
String? senderName, String? senderName,
String? messageId, String? messageId,
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
String? mapId,
}) { }) {
final typeNum = json['t'] as int?; 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; return null;
} }
try { try {
final type = DrawingShapeType.values[typeNum]; final type = DrawingShapeType.values[typeNum];
switch (type) { switch (type) {
case DrawingShapeType.line: case DrawingShapeType.line:
return LineDrawing.fromNetworkJson( return LineDrawing.fromNetworkJson(
json, json,
senderName: senderName, senderName: senderName,
messageId: messageId, messageId: messageId,
coordinateSpace: coordinateSpace,
mapId: mapId,
); );
case DrawingShapeType.rectangle: case DrawingShapeType.rectangle:
return RectangleDrawing.fromNetworkJson( return RectangleDrawing.fromNetworkJson(
json, json,
senderName: senderName, senderName: senderName,
messageId: messageId, messageId: messageId,
coordinateSpace: coordinateSpace,
mapId: mapId,
); );
} }
} catch (e) { } catch (_) {
return null; return null;
} }
} }
/// Create from JSON
static MapDrawing? fromJson(Map<String, dynamic> json) { static MapDrawing? fromJson(Map<String, dynamic> json) {
final typeStr = json['type'] as String?; final typeStr = json['type'] as String?;
if (typeStr == null) return null; if (typeStr == null) return null;
try { try {
final type = DrawingShapeType.values.firstWhere( final type = DrawingShapeType.values.firstWhere(
(e) => e.toString() == 'DrawingShapeType.$typeStr', (value) => value.name == typeStr,
); );
switch (type) { switch (type) {
case DrawingShapeType.line: case DrawingShapeType.line:
return LineDrawing.fromJson(json); return LineDrawing.fromJson(json);
case DrawingShapeType.rectangle: case DrawingShapeType.rectangle:
return RectangleDrawing.fromJson(json); return RectangleDrawing.fromJson(json);
} }
} catch (e) { } catch (_) {
return null; return null;
} }
} }
/// Get the center point of the drawing
LatLng getCenter(); LatLng getCenter();
/// Get the bounds of the drawing
LatLngBounds getBounds(); LatLngBounds getBounds();
} }
/// Line drawing on map
class LineDrawing extends MapDrawing { class LineDrawing extends MapDrawing {
final List<LatLng> points; final List<LatLng> points;
@@ -175,6 +165,8 @@ class LineDrawing extends MapDrawing {
super.isShared, super.isShared,
super.isSent, super.isSent,
super.isHidden, super.isHidden,
super.coordinateSpace,
super.mapId,
}) : super(type: DrawingShapeType.line); }) : super(type: DrawingShapeType.line);
@override @override
@@ -184,31 +176,51 @@ class LineDrawing extends MapDrawing {
'type': type.name, 'type': type.name,
'color': color.toARGB32(), 'color': color.toARGB32(),
'createdAt': createdAt.toIso8601String(), '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, 'isShared': isShared,
// Note: isHidden is not persisted - it's session-only 'coordinateSpace': coordinateSpace.name,
'mapId': normalizeCustomMapId(mapId),
}; };
} }
@override @override
Map<String, dynamic> toNetworkJson() { Map<String, dynamic> toNetworkJson() {
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points final payload = <String, dynamic>{
// Points are encoded as flat array [lat1,lon1,lat2,lon2,...]
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
// Sender is fetched from packet metadata, not included in JSON
return {
't': type.index, 't': type.index,
'c': DrawingColors.colorToIndex(color), '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<String, dynamic> json) { static LineDrawing fromJson(Map<String, dynamic> json) {
final pointsJson = json['points'] as List<dynamic>; final pointsJson = json['points'] as List<dynamic>;
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?; final senderName = json['sender'] as String?;
return LineDrawing( return LineDrawing(
@@ -217,8 +229,12 @@ class LineDrawing extends MapDrawing {
createdAt: DateTime.parse(json['createdAt'] as String), createdAt: DateTime.parse(json['createdAt'] as String),
points: points, points: points,
senderName: senderName, senderName: senderName,
isReceived: senderName != null, // Mark as received if sender is present isReceived: senderName != null,
isShared: json['isShared'] as bool? ?? false, 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<String, dynamic> json, { Map<String, dynamic> json, {
String? senderName, String? senderName,
String? messageId, String? messageId,
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
String? mapId,
}) { }) {
// Parse ultra-compact format final flatPoints = (json['p'] as List<dynamic>).cast<num>();
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
final points = <LatLng>[]; final points = <LatLng>[];
for (int i = 0; i < pointsFlat.length; i += 2) { for (int i = 0; i < flatPoints.length; i += 2) {
points.add(LatLng(pointsFlat[i], pointsFlat[i + 1])); points.add(
LatLng(flatPoints[i].toDouble(), flatPoints[i + 1].toDouble()),
);
} }
return LineDrawing( return LineDrawing(
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID id: DateTime.now().millisecondsSinceEpoch.toString(),
color: DrawingColors.indexToColor(json['c'] as int), color: DrawingColors.indexToColor(json['c'] as int),
createdAt: DateTime.now(), createdAt: DateTime.now(),
points: points, points: points,
senderName: senderName, senderName: senderName,
isReceived: true, isReceived: true,
messageId: messageId, // Link to source message messageId: messageId,
isShared: false, // Received drawings are not marked as shared coordinateSpace: coordinateSpace,
mapId: normalizeCustomMapId(mapId),
); );
} }
/// Create a copy with updated points LineDrawing copyWith({
LineDrawing copyWith({List<LatLng>? points}) { List<LatLng>? points,
bool? isHidden,
bool? isShared,
bool? isReceived,
String? messageId,
String? senderName,
MapCoordinateSpace? coordinateSpace,
String? mapId,
}) {
return LineDrawing( return LineDrawing(
id: id, id: id,
color: color, color: color,
createdAt: createdAt, createdAt: createdAt,
points: points ?? this.points, 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 @override
LatLng getCenter() { LatLng getCenter() {
if (points.isEmpty) return LatLng(0, 0); if (points.isEmpty) return const LatLng(0, 0);
if (points.length == 1) return points[0]; if (points.length == 1) return points[0];
// Calculate center as average of all points
double sumLat = 0; double sumLat = 0;
double sumLon = 0; double sumLon = 0;
for (final point in points) { for (final point in points) {
@@ -273,8 +307,12 @@ class LineDrawing extends MapDrawing {
@override @override
LatLngBounds getBounds() { LatLngBounds getBounds() {
if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0)); if (points.isEmpty) {
if (points.length == 1) return LatLngBounds(points[0], points[0]); 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 minLat = points[0].latitude;
double maxLat = points[0].latitude; double maxLat = points[0].latitude;
@@ -292,7 +330,6 @@ class LineDrawing extends MapDrawing {
} }
} }
/// Rectangle drawing on map
class RectangleDrawing extends MapDrawing { class RectangleDrawing extends MapDrawing {
final LatLng topLeft; final LatLng topLeft;
final LatLng bottomRight; final LatLng bottomRight;
@@ -309,16 +346,17 @@ class RectangleDrawing extends MapDrawing {
super.isShared, super.isShared,
super.isSent, super.isSent,
super.isHidden, super.isHidden,
super.coordinateSpace,
super.mapId,
}) : super(type: DrawingShapeType.rectangle); }) : super(type: DrawingShapeType.rectangle);
/// Get all corner points for rendering
List<LatLng> get corners => [ List<LatLng> get corners => [
topLeft, topLeft,
LatLng(topLeft.latitude, bottomRight.longitude), // top right LatLng(topLeft.latitude, bottomRight.longitude),
bottomRight, bottomRight,
LatLng(bottomRight.latitude, topLeft.longitude), // bottom left LatLng(bottomRight.latitude, topLeft.longitude),
topLeft, // close the rectangle topLeft,
]; ];
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@@ -328,27 +366,41 @@ class RectangleDrawing extends MapDrawing {
'color': color.toARGB32(), 'color': color.toARGB32(),
'createdAt': createdAt.toIso8601String(), 'createdAt': createdAt.toIso8601String(),
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude}, 'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude}, 'bottomRight': {
'lat': bottomRight.latitude,
'lon': bottomRight.longitude,
},
'isShared': isShared, 'isShared': isShared,
// Note: isHidden is not persisted - it's session-only 'coordinateSpace': coordinateSpace.name,
'mapId': normalizeCustomMapId(mapId),
}; };
} }
@override @override
Map<String, dynamic> toNetworkJson() { Map<String, dynamic> toNetworkJson() {
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2] final payload = <String, dynamic>{
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
// Sender is fetched from packet metadata, not included in JSON
return {
't': type.index, 't': type.index,
'c': DrawingColors.colorToIndex(color), '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<String, dynamic> json) { static RectangleDrawing fromJson(Map<String, dynamic> json) {
@@ -360,11 +412,21 @@ class RectangleDrawing extends MapDrawing {
id: json['id'] as String, id: json['id'] as String,
color: Color(json['color'] as int), color: Color(json['color'] as int),
createdAt: DateTime.parse(json['createdAt'] as String), createdAt: DateTime.parse(json['createdAt'] as String),
topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double), topLeft: LatLng(
bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double), (topLeftJson['lat'] as num).toDouble(),
(topLeftJson['lon'] as num).toDouble(),
),
bottomRight: LatLng(
(bottomRightJson['lat'] as num).toDouble(),
(bottomRightJson['lon'] as num).toDouble(),
),
senderName: senderName, senderName: senderName,
isReceived: senderName != null, // Mark as received if sender is present isReceived: senderName != null,
isShared: json['isShared'] as bool? ?? false, 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<String, dynamic> json, { Map<String, dynamic> json, {
String? senderName, String? senderName,
String? messageId, String? messageId,
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
String? mapId,
}) { }) {
// Parse ultra-compact format final bounds = (json['b'] as List<dynamic>).cast<num>();
final bounds = (json['b'] as List<dynamic>).cast<double>();
return RectangleDrawing( return RectangleDrawing(
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID id: DateTime.now().millisecondsSinceEpoch.toString(),
color: DrawingColors.indexToColor(json['c'] as int), color: DrawingColors.indexToColor(json['c'] as int),
createdAt: DateTime.now(), createdAt: DateTime.now(),
topLeft: LatLng(bounds[0], bounds[1]), topLeft: LatLng(bounds[0].toDouble(), bounds[1].toDouble()),
bottomRight: LatLng(bounds[2], bounds[3]), bottomRight: LatLng(bounds[2].toDouble(), bounds[3].toDouble()),
senderName: senderName, senderName: senderName,
isReceived: true, isReceived: true,
messageId: messageId, // Link to source message messageId: messageId,
isShared: false, // Received drawings are not marked as shared coordinateSpace: coordinateSpace,
mapId: normalizeCustomMapId(mapId),
); );
} }
/// Create a copy with updated corners
RectangleDrawing copyWith({ RectangleDrawing copyWith({
LatLng? topLeft, LatLng? topLeft,
LatLng? bottomRight, LatLng? bottomRight,
bool? isHidden,
bool? isShared,
bool? isReceived,
String? messageId,
String? senderName,
MapCoordinateSpace? coordinateSpace,
String? mapId,
}) { }) {
return RectangleDrawing( return RectangleDrawing(
id: id, id: id,
@@ -400,12 +470,18 @@ class RectangleDrawing extends MapDrawing {
createdAt: createdAt, createdAt: createdAt,
topLeft: topLeft ?? this.topLeft, topLeft: topLeft ?? this.topLeft,
bottomRight: bottomRight ?? this.bottomRight, 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 @override
LatLng getCenter() { LatLng getCenter() {
// Center is the midpoint between top-left and bottom-right
return LatLng( return LatLng(
(topLeft.latitude + bottomRight.latitude) / 2, (topLeft.latitude + bottomRight.latitude) / 2,
(topLeft.longitude + bottomRight.longitude) / 2, (topLeft.longitude + bottomRight.longitude) / 2,
@@ -414,7 +490,6 @@ class RectangleDrawing extends MapDrawing {
@override @override
LatLngBounds getBounds() { LatLngBounds getBounds() {
// Bounds are simply the two corners
return LatLngBounds(topLeft, bottomRight); return LatLngBounds(topLeft, bottomRight);
} }
} }

View File

@@ -9,6 +9,7 @@ export 'package:meshcore_client/meshcore_client.dart'
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart'; import 'package:meshcore_client/meshcore_client.dart';
import 'sar_marker.dart'; import 'sar_marker.dart';
import 'map_coordinate_space.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
extension MessageVoiceExtension on Message { extension MessageVoiceExtension on Message {
@@ -47,7 +48,7 @@ extension MessageSarExtension on Message {
/// Convert to a [SarMarker] if this message contains SAR data. /// Convert to a [SarMarker] if this message contains SAR data.
SarMarker? toSarMarker() { SarMarker? toSarMarker() {
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) { if (!isSarMarker || sarMarkerType == null) {
return null; return null;
} }
@@ -57,16 +58,26 @@ extension MessageSarExtension on Message {
debugPrint(' message.sarMarkerType: $sarMarkerType'); debugPrint(' message.sarMarkerType: $sarMarkerType');
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"'); 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( return SarMarker(
id: id, id: id,
type: sarMarkerType!, type: sarMarkerType!,
location: sarGpsCoordinates!, location: location,
timestamp: sentAt, timestamp: sentAt,
senderPublicKey: senderPublicKeyPrefix, senderPublicKey: senderPublicKeyPrefix,
senderName: senderName, senderName: senderName,
notes: sarNotes, notes: sarNotes,
customEmoji: sarCustomEmoji, customEmoji: sarCustomEmoji,
colorIndex: sarColorIndex, colorIndex: sarColorIndex,
coordinateSpace: coordinateSpace,
mapId: sarCustomMapId,
); );
} }
} }

View File

@@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import 'map_coordinate_space.dart';
import '../services/sar_template_service.dart'; import '../services/sar_template_service.dart';
/// SAR (Search & Rescue) marker types /// SAR (Search & Rescue) marker types
@@ -78,6 +79,8 @@ class SarMarker {
final String? notes; final String? notes;
final String? customEmoji; // For custom SAR markers not in predefined types final String? customEmoji; // For custom SAR markers not in predefined types
final int? colorIndex; // Color index (0-7) from standard palette final int? colorIndex; // Color index (0-7) from standard palette
final MapCoordinateSpace coordinateSpace;
final String? mapId;
SarMarker({ SarMarker({
required this.id, required this.id,
@@ -89,8 +92,13 @@ class SarMarker {
this.notes, this.notes,
this.customEmoji, this.customEmoji,
this.colorIndex, this.colorIndex,
this.coordinateSpace = MapCoordinateSpace.geo,
this.mapId,
}); });
bool get isCustomMapMarker =>
coordinateSpace == MapCoordinateSpace.customMap && mapId != null;
/// Get sender public key as hex string (short) /// Get sender public key as hex string (short)
String? get senderKeyShort { String? get senderKeyShort {
if (senderPublicKey == null || senderPublicKey!.length < 8) return null; if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
@@ -167,6 +175,8 @@ class SarMarker {
String? notes, String? notes,
String? customEmoji, String? customEmoji,
int? colorIndex, int? colorIndex,
MapCoordinateSpace? coordinateSpace,
String? mapId,
}) { }) {
return SarMarker( return SarMarker(
id: id ?? this.id, id: id ?? this.id,
@@ -178,6 +188,8 @@ class SarMarker {
notes: notes ?? this.notes, notes: notes ?? this.notes,
customEmoji: customEmoji ?? this.customEmoji, customEmoji: customEmoji ?? this.customEmoji,
colorIndex: colorIndex ?? this.colorIndex, colorIndex: colorIndex ?? this.colorIndex,
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
mapId: mapId ?? this.mapId,
); );
} }

View File

@@ -1,8 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import '../models/map_coordinate_space.dart';
import '../utils/drawing_message_parser.dart'; import '../utils/drawing_message_parser.dart';
/// Drawing mode state /// Drawing mode state
@@ -33,12 +35,18 @@ class DrawingProvider with ChangeNotifier {
LatLng? _measurementPoint1; LatLng? _measurementPoint1;
LatLng? _measurementPoint2; LatLng? _measurementPoint2;
double? _measuredDistance; // in meters double? _measuredDistance; // in meters
MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo;
String? _activeMapId;
double? _activeMetersPerPixel;
// Getters // Getters
DrawingMode get drawingMode => _drawingMode; DrawingMode get drawingMode => _drawingMode;
Color get selectedColor => _selectedColor; Color get selectedColor => _selectedColor;
bool get showReceivedDrawings => _showReceivedDrawings; bool get showReceivedDrawings => _showReceivedDrawings;
bool get showSarMarkers => _showSarMarkers; bool get showSarMarkers => _showSarMarkers;
MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace;
String? get activeMapId => _activeMapId;
double? get activeMetersPerPixel => _activeMetersPerPixel;
List<MapDrawing> get drawings { List<MapDrawing> get drawings {
// Filter out hidden drawings first // Filter out hidden drawings first
var visibleDrawings = _drawings.where((d) => !d.isHidden); var visibleDrawings = _drawings.where((d) => !d.isHidden);
@@ -48,8 +56,19 @@ class DrawingProvider with ChangeNotifier {
visibleDrawings = visibleDrawings.where((d) => !d.isReceived); 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()); return List.unmodifiable(visibleDrawings.toList());
} }
MapDrawing? get currentDrawing => _currentDrawing; MapDrawing? get currentDrawing => _currentDrawing;
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints); List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
LatLng? get rectangleStartPoint => _rectangleStartPoint; LatLng? get rectangleStartPoint => _rectangleStartPoint;
@@ -66,6 +85,29 @@ class DrawingProvider with ChangeNotifier {
_isInitialized = true; _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 /// Set drawing mode
void setDrawingMode(DrawingMode mode) { void setDrawingMode(DrawingMode mode) {
if (_drawingMode != mode) { if (_drawingMode != mode) {
@@ -137,6 +179,10 @@ class DrawingProvider with ChangeNotifier {
color: _selectedColor, color: _selectedColor,
createdAt: DateTime.now(), createdAt: DateTime.now(),
points: List.from(_currentLinePoints), points: List.from(_currentLinePoints),
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
); );
_drawings.add(drawing); _drawings.add(drawing);
@@ -180,6 +226,10 @@ class DrawingProvider with ChangeNotifier {
? _rectangleStartPoint!.longitude ? _rectangleStartPoint!.longitude
: endPoint.longitude, : endPoint.longitude,
), ),
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
); );
notifyListeners(); notifyListeners();
} }
@@ -216,6 +266,10 @@ class DrawingProvider with ChangeNotifier {
createdAt: DateTime.now(), createdAt: DateTime.now(),
topLeft: topLeft, topLeft: topLeft,
bottomRight: bottomRight, bottomRight: bottomRight,
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
); );
_drawings.add(drawing); _drawings.add(drawing);
@@ -237,7 +291,9 @@ class DrawingProvider with ChangeNotifier {
/// Set second measurement point and calculate distance /// Set second measurement point and calculate distance
void setMeasurementPoint2(LatLng point) { void setMeasurementPoint2(LatLng point) {
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) return; if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) {
return;
}
_measurementPoint2 = point; _measurementPoint2 = point;
_measuredDistance = _calculateDistance(_measurementPoint1!, point); _measuredDistance = _calculateDistance(_measurementPoint1!, point);
@@ -246,6 +302,13 @@ class DrawingProvider with ChangeNotifier {
/// Calculate distance between two points using Haversine formula /// Calculate distance between two points using Haversine formula
double _calculateDistance(LatLng point1, LatLng point2) { 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(); const Distance distance = Distance();
return distance.as(LengthUnit.Meter, point1, point2); return distance.as(LengthUnit.Meter, point1, point2);
} }
@@ -339,6 +402,10 @@ class DrawingProvider with ChangeNotifier {
color: _selectedColor, color: _selectedColor,
createdAt: DateTime.now(), createdAt: DateTime.now(),
points: _currentLinePoints, points: _currentLinePoints,
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
); );
} else if (_drawingMode == DrawingMode.rectangle && } else if (_drawingMode == DrawingMode.rectangle &&
_currentDrawing != null) { _currentDrawing != null) {
@@ -376,6 +443,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared, isShared: drawing.isShared,
isSent: drawing.isSent, isSent: drawing.isSent,
isHidden: drawing.isHidden, isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
); );
} else if (drawing is RectangleDrawing) { } else if (drawing is RectangleDrawing) {
return RectangleDrawing( return RectangleDrawing(
@@ -390,6 +459,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared, isShared: drawing.isShared,
isSent: drawing.isSent, isSent: drawing.isSent,
isHidden: drawing.isHidden, isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
); );
} }
return drawing; return drawing;
@@ -406,7 +477,7 @@ class DrawingProvider with ChangeNotifier {
/// Get all unshared drawings (local drawings not yet sent) /// Get all unshared drawings (local drawings not yet sent)
List<MapDrawing> getUnsharedDrawings() { List<MapDrawing> getUnsharedDrawings() {
return _drawings.where((d) => !d.isShared && !d.isReceived).toList(); return drawings.where((d) => !d.isShared && !d.isReceived).toList();
} }
/// Mark a drawing as shared /// Mark a drawing as shared
@@ -428,6 +499,8 @@ class DrawingProvider with ChangeNotifier {
isShared: true, isShared: true,
isSent: drawing.isSent, isSent: drawing.isSent,
isHidden: drawing.isHidden, isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
); );
} else if (drawing is RectangleDrawing) { } else if (drawing is RectangleDrawing) {
_drawings[index] = RectangleDrawing( _drawings[index] = RectangleDrawing(
@@ -442,6 +515,8 @@ class DrawingProvider with ChangeNotifier {
isShared: true, isShared: true,
isSent: drawing.isSent, isSent: drawing.isSent,
isHidden: drawing.isHidden, isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
); );
} }
@@ -469,6 +544,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared, isShared: drawing.isShared,
isSent: drawing.isSent, isSent: drawing.isSent,
isHidden: !drawing.isHidden, isHidden: !drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
); );
} else if (drawing is RectangleDrawing) { } else if (drawing is RectangleDrawing) {
_drawings[index] = RectangleDrawing( _drawings[index] = RectangleDrawing(
@@ -483,6 +560,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared, isShared: drawing.isShared,
isSent: drawing.isSent, isSent: drawing.isSent,
isHidden: !drawing.isHidden, isHidden: !drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
); );
} }

View File

@@ -1,30 +1,47 @@
import 'dart:async'; 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/foundation.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:image_picker/image_picker.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/custom_map_config.dart';
import '../models/location_trail.dart'; import '../models/location_trail.dart';
import '../models/map_coordinate_space.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import '../models/sar_marker.dart';
import '../utils/custom_map_id.dart';
class MapProvider with ChangeNotifier { 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() { MapProvider() {
unawaited(_loadInitialState()); unawaited(_loadInitialState());
} }
final ImagePicker _imagePicker = ImagePicker();
LatLng? _targetLocation; LatLng? _targetLocation;
LatLngBounds? _targetBounds;
double? _targetZoom; double? _targetZoom;
bool _shouldAnimate = false; bool _shouldAnimate = false;
MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo;
String? _targetMapId;
// Track which contact paths are currently visible
final Set<String> _visibleContactPaths = {}; final Set<String> _visibleContactPaths = {};
// Location trail tracking
LocationTrail? _currentTrail; LocationTrail? _currentTrail;
bool _isTrailVisible = true; bool _isTrailVisible = true;
final List<LocationTrail> _trailHistory = []; final List<LocationTrail> _trailHistory = [];
// WMS overlay toggles
bool _showCadastralOverlay = false; bool _showCadastralOverlay = false;
bool _showForestRoadsOverlay = false; bool _showForestRoadsOverlay = false;
bool _showHikingTrailsOverlay = false; bool _showHikingTrailsOverlay = false;
@@ -37,29 +54,30 @@ class MapProvider with ChangeNotifier {
bool _showPlaceNamesOverlay = false; bool _showPlaceNamesOverlay = false;
bool _showMunicipalityBordersOverlay = false; bool _showMunicipalityBordersOverlay = false;
// Contact trail toggles bool _showAllContactTrails = true;
bool _showAllContactTrails = true; // Default to showing all contact trails
bool _hideRepeatersOnMap = false; bool _hideRepeatersOnMap = false;
// Imported trail (from GPX)
LocationTrail? _importedTrail; LocationTrail? _importedTrail;
// Download area selection
bool _isSelectingDownloadArea = false; bool _isSelectingDownloadArea = false;
LatLngBounds? _downloadAreaBounds; LatLngBounds? _downloadAreaBounds;
CustomMapConfig? _customMapConfig;
bool _isUsingCustomMap = false;
LatLng? get targetLocation => _targetLocation; LatLng? get targetLocation => _targetLocation;
LatLngBounds? get targetBounds => _targetBounds;
double? get targetZoom => _targetZoom; double? get targetZoom => _targetZoom;
bool get shouldAnimate => _shouldAnimate; bool get shouldAnimate => _shouldAnimate;
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
String? get targetMapId => _targetMapId;
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths); Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
// Trail getters
LocationTrail? get currentTrail => _currentTrail; LocationTrail? get currentTrail => _currentTrail;
bool get isTrailVisible => _isTrailVisible; bool get isTrailVisible => _isTrailVisible;
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory); List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
bool get isTrailActive => _currentTrail?.isActive ?? false; bool get isTrailActive => _currentTrail?.isActive ?? false;
// WMS overlay getters
bool get showCadastralOverlay => _showCadastralOverlay; bool get showCadastralOverlay => _showCadastralOverlay;
bool get showForestRoadsOverlay => _showForestRoadsOverlay; bool get showForestRoadsOverlay => _showForestRoadsOverlay;
bool get showHikingTrailsOverlay => _showHikingTrailsOverlay; bool get showHikingTrailsOverlay => _showHikingTrailsOverlay;
@@ -72,69 +90,135 @@ class MapProvider with ChangeNotifier {
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay; bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay; bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
// Contact trail getters
bool get showAllContactTrails => _showAllContactTrails; bool get showAllContactTrails => _showAllContactTrails;
bool get hideRepeatersOnMap => _hideRepeatersOnMap; bool get hideRepeatersOnMap => _hideRepeatersOnMap;
// Imported trail getters
LocationTrail? get importedTrail => _importedTrail; LocationTrail? get importedTrail => _importedTrail;
// Download area getters
bool get isSelectingDownloadArea => _isSelectingDownloadArea; bool get isSelectingDownloadArea => _isSelectingDownloadArea;
LatLngBounds? get downloadAreaBounds => _downloadAreaBounds; 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({ void navigateToLocation({
required LatLng location, required LatLng location,
double zoom = 15.0, double zoom = 15.0,
bool animate = true, bool animate = true,
}) { }) {
if (_isUsingCustomMap) {
_isUsingCustomMap = false;
unawaited(_saveCustomMapState());
}
_targetLocation = location; _targetLocation = location;
_targetBounds = null;
_targetZoom = zoom; _targetZoom = zoom;
_shouldAnimate = animate; _shouldAnimate = animate;
_targetCoordinateSpace = MapCoordinateSpace.geo;
_targetMapId = null;
notifyListeners(); 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() { void clearNavigation() {
_targetLocation = null; _targetLocation = null;
_targetBounds = null;
_targetZoom = null; _targetZoom = null;
_shouldAnimate = false; _shouldAnimate = false;
// Don't notify listeners to avoid rebuilds _targetCoordinateSpace = MapCoordinateSpace.geo;
_targetMapId = null;
} }
/// Navigate to a drawing by its ID String? navigateToDrawing(String drawingId, dynamic drawingProvider) {
void navigateToDrawing(String drawingId, dynamic drawingProvider) { final drawing = drawingProvider.getDrawingById(drawingId) as MapDrawing?;
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<dynamic>().firstWhere(
(d) => d.id == drawingId,
orElse: () => null,
);
if (drawing == null) { if (drawing == null) {
debugPrint('⚠️ [MapProvider] Drawing $drawingId not found'); return 'Drawing not found.';
debugPrint('⚠️ [MapProvider] Available drawing IDs: ${drawings.map((d) => d.id).toList()}');
return;
} }
// Use MapDrawing's built-in getCenter and getBounds methods if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
final center = drawing.getCenter(); if (!matchesActiveCustomMap(drawing.mapId)) {
final bounds = drawing.getBounds(); 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 final bounds = drawing.getBounds();
// For larger drawings, use lower zoom to fit the whole drawing
// For smaller drawings, use higher zoom for better detail
final latDiff = (bounds.north - bounds.south).abs(); final latDiff = (bounds.north - bounds.south).abs();
final lonDiff = (bounds.east - bounds.west).abs(); final lonDiff = (bounds.east - bounds.west).abs();
final maxDiff = latDiff > lonDiff ? latDiff : lonDiff; 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; double zoom = 15.0;
if (maxDiff < 0.001) { if (maxDiff < 0.001) {
zoom = 17.0; zoom = 17.0;
@@ -150,9 +234,125 @@ class MapProvider with ChangeNotifier {
zoom = 10.0; zoom = 10.0;
} }
final typeStr = drawing is LineDrawing ? 'line' : 'rectangle'; navigateToLocation(
debugPrint('🗺️ [MapProvider] Navigating to drawing: $typeStr, zoom: $zoom'); location: drawing.getCenter(),
navigateToLocation(location: center, zoom: zoom, animate: true); 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<bool> 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<void> replaceCustomMap() async {
await loadCustomMapFromGallery();
}
Future<void> 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<void> 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<void> clearCustomMapCalibration() async {
if (_customMapConfig == null) return;
_customMapConfig = _customMapConfig!.copyWith(
clearMetersPerPixel: true,
clearCalibrationPointA: true,
clearCalibrationPointB: true,
);
await _saveCustomMapState();
notifyListeners();
}
Future<void> removeCustomMap() async {
final filePath = _customMapConfig?.filePath;
_customMapConfig = null;
_isUsingCustomMap = false;
clearNavigation();
await _saveCustomMapState();
if (filePath != null) {
await _deleteFileIfExists(filePath);
}
notifyListeners();
}
Future<void> enterCustomMapMode() async {
if (!hasCustomMap || _isUsingCustomMap) return;
_isUsingCustomMap = true;
await _saveCustomMapState();
notifyListeners();
}
Future<void> exitCustomMapMode() async {
if (!_isUsingCustomMap) return;
_isUsingCustomMap = false;
await _saveCustomMapState();
notifyListeners();
} }
void updateZoom(double zoom) { void updateZoom(double zoom) {
@@ -160,7 +360,6 @@ class MapProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Toggle path visibility for a contact
void toggleContactPath(String publicKeyHex) { void toggleContactPath(String publicKeyHex) {
if (_visibleContactPaths.contains(publicKeyHex)) { if (_visibleContactPaths.contains(publicKeyHex)) {
_visibleContactPaths.remove(publicKeyHex); _visibleContactPaths.remove(publicKeyHex);
@@ -170,27 +369,22 @@ class MapProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Check if a contact's path is visible
bool isContactPathVisible(String publicKeyHex) { bool isContactPathVisible(String publicKeyHex) {
return _visibleContactPaths.contains(publicKeyHex); return _visibleContactPaths.contains(publicKeyHex);
} }
/// Hide all contact paths
void hideAllPaths() { void hideAllPaths() {
_visibleContactPaths.clear(); _visibleContactPaths.clear();
notifyListeners(); notifyListeners();
} }
/// Show path for specific contact (hide all others)
void showOnlyPath(String publicKeyHex) { void showOnlyPath(String publicKeyHex) {
_visibleContactPaths.clear(); _visibleContactPaths.clear();
_visibleContactPaths.add(publicKeyHex); _visibleContactPaths.add(publicKeyHex);
notifyListeners(); notifyListeners();
} }
/// Start a new location trail
void startTrail() { void startTrail() {
// End current trail if active
if (_currentTrail != null && _currentTrail!.isActive) { if (_currentTrail != null && _currentTrail!.isActive) {
endTrail(); endTrail();
} }
@@ -203,22 +397,22 @@ class MapProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Add a point to the current trail
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) { void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
if (_currentTrail == null || !_currentTrail!.isActive) { if (_currentTrail == null || !_currentTrail!.isActive) {
startTrail(); startTrail();
} }
_currentTrail!.addPoint(TrailPoint( _currentTrail!.addPoint(
position: position, TrailPoint(
timestamp: DateTime.now(), position: position,
accuracy: accuracy, timestamp: DateTime.now(),
speed: speed, accuracy: accuracy,
)); speed: speed,
),
);
notifyListeners(); notifyListeners();
} }
/// End the current trail
void endTrail() { void endTrail() {
if (_currentTrail != null) { if (_currentTrail != null) {
_currentTrail!.isActive = false; _currentTrail!.isActive = false;
@@ -231,13 +425,11 @@ class MapProvider with ChangeNotifier {
} }
} }
/// Toggle trail visibility
void toggleTrailVisibility() { void toggleTrailVisibility() {
_isTrailVisible = !_isTrailVisible; _isTrailVisible = !_isTrailVisible;
notifyListeners(); notifyListeners();
} }
/// Clear the current trail
void clearCurrentTrail() { void clearCurrentTrail() {
if (_currentTrail != null) { if (_currentTrail != null) {
_currentTrail = null; _currentTrail = null;
@@ -245,155 +437,173 @@ class MapProvider with ChangeNotifier {
} }
} }
/// Clear all trail history
void clearAllTrails() { void clearAllTrails() {
_currentTrail = null; _currentTrail = null;
_trailHistory.clear(); _trailHistory.clear();
notifyListeners(); notifyListeners();
} }
/// Get total trail distance in meters
double get totalTrailDistance { double get totalTrailDistance {
if (_currentTrail == null) return 0; if (_currentTrail == null) return 0;
return _currentTrail!.totalDistance; return _currentTrail!.totalDistance;
} }
/// Get trail duration
Duration get trailDuration { Duration get trailDuration {
if (_currentTrail == null) return Duration.zero; if (_currentTrail == null) return Duration.zero;
return _currentTrail!.duration; return _currentTrail!.duration;
} }
/// Toggle cadastral parcels overlay
Future<void> toggleCadastralOverlay() async { Future<void> toggleCadastralOverlay() async {
_showCadastralOverlay = !_showCadastralOverlay; _showCadastralOverlay = !_showCadastralOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Toggle forest roads overlay
Future<void> toggleForestRoadsOverlay() async { Future<void> toggleForestRoadsOverlay() async {
_showForestRoadsOverlay = !_showForestRoadsOverlay; _showForestRoadsOverlay = !_showForestRoadsOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Toggle hiking trails overlay
Future<void> toggleHikingTrailsOverlay() async { Future<void> toggleHikingTrailsOverlay() async {
_showHikingTrailsOverlay = !_showHikingTrailsOverlay; _showHikingTrailsOverlay = !_showHikingTrailsOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Toggle main roads overlay
Future<void> toggleMainRoadsOverlay() async { Future<void> toggleMainRoadsOverlay() async {
_showMainRoadsOverlay = !_showMainRoadsOverlay; _showMainRoadsOverlay = !_showMainRoadsOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Toggle house numbers overlay
Future<void> toggleHouseNumbersOverlay() async { Future<void> toggleHouseNumbersOverlay() async {
_showHouseNumbersOverlay = !_showHouseNumbersOverlay; _showHouseNumbersOverlay = !_showHouseNumbersOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Toggle fire hazard zones overlay
Future<void> toggleFireHazardZonesOverlay() async { Future<void> toggleFireHazardZonesOverlay() async {
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay; _showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Toggle historical fires overlay
Future<void> toggleHistoricalFiresOverlay() async { Future<void> toggleHistoricalFiresOverlay() async {
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay; _showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Toggle firebreaks overlay
Future<void> toggleFirebreaksOverlay() async { Future<void> toggleFirebreaksOverlay() async {
_showFirebreaksOverlay = !_showFirebreaksOverlay; _showFirebreaksOverlay = !_showFirebreaksOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Toggle Kras fire zones overlay
Future<void> toggleKrasFireZonesOverlay() async { Future<void> toggleKrasFireZonesOverlay() async {
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay; _showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Toggle place names overlay
Future<void> togglePlaceNamesOverlay() async { Future<void> togglePlaceNamesOverlay() async {
_showPlaceNamesOverlay = !_showPlaceNamesOverlay; _showPlaceNamesOverlay = !_showPlaceNamesOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Toggle municipality borders overlay
Future<void> toggleMunicipalityBordersOverlay() async { Future<void> toggleMunicipalityBordersOverlay() async {
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay; _showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
notifyListeners(); notifyListeners();
await _saveOverlayState(); await _saveOverlayState();
} }
/// Load overlay state from SharedPreferences
Future<void> loadOverlayState() async { Future<void> loadOverlayState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_showCadastralOverlay = prefs.getBool('map_show_cadastral_overlay') ?? false; _showCadastralOverlay =
_showForestRoadsOverlay = prefs.getBool('map_show_forest_roads_overlay') ?? false; prefs.getBool('map_show_cadastral_overlay') ?? false;
_showHikingTrailsOverlay = prefs.getBool('map_show_hiking_trails_overlay') ?? false; _showForestRoadsOverlay =
_showMainRoadsOverlay = prefs.getBool('map_show_main_roads_overlay') ?? false; prefs.getBool('map_show_forest_roads_overlay') ?? false;
_showHouseNumbersOverlay = prefs.getBool('map_show_house_numbers_overlay') ?? false; _showHikingTrailsOverlay =
_showFireHazardZonesOverlay = prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false; prefs.getBool('map_show_hiking_trails_overlay') ?? false;
_showHistoricalFiresOverlay = prefs.getBool('map_show_historical_fires_overlay') ?? false; _showMainRoadsOverlay =
_showFirebreaksOverlay = prefs.getBool('map_show_firebreaks_overlay') ?? false; prefs.getBool('map_show_main_roads_overlay') ?? false;
_showKrasFireZonesOverlay = prefs.getBool('map_show_kras_fire_zones_overlay') ?? false; _showHouseNumbersOverlay =
_showPlaceNamesOverlay = prefs.getBool('map_show_place_names_overlay') ?? false; prefs.getBool('map_show_house_numbers_overlay') ?? false;
_showMunicipalityBordersOverlay = prefs.getBool('map_show_municipality_borders_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(); notifyListeners();
} }
Future<void> _loadInitialState() async { Future<void> _loadInitialState() async {
await Future.wait([loadOverlayState(), loadTrailSettings()]); await Future.wait([
await loadRepeaterVisibilitySettings(); loadOverlayState(),
loadTrailSettings(),
loadRepeaterVisibilitySettings(),
_loadCustomMapState(),
]);
} }
/// Save overlay state to SharedPreferences
Future<void> _saveOverlayState() async { Future<void> _saveOverlayState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay); await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay);
await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay); await prefs.setBool(
await prefs.setBool('map_show_hiking_trails_overlay', _showHikingTrailsOverlay); '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_main_roads_overlay', _showMainRoadsOverlay);
await prefs.setBool('map_show_house_numbers_overlay', _showHouseNumbersOverlay); await prefs.setBool(
await prefs.setBool('map_show_fire_hazard_zones_overlay', _showFireHazardZonesOverlay); 'map_show_house_numbers_overlay',
await prefs.setBool('map_show_historical_fires_overlay', _showHistoricalFiresOverlay); _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_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_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<void> toggleAllContactTrails() async { Future<void> toggleAllContactTrails() async {
_showAllContactTrails = !_showAllContactTrails; _showAllContactTrails = !_showAllContactTrails;
notifyListeners(); notifyListeners();
await _saveTrailSettings(); await _saveTrailSettings();
} }
/// Load trail settings from SharedPreferences
Future<void> loadTrailSettings() async { Future<void> loadTrailSettings() async {
final prefs = await SharedPreferences.getInstance(); 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(); notifyListeners();
} }
/// Save trail settings to SharedPreferences
Future<void> _saveTrailSettings() async { Future<void> _saveTrailSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails); await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
@@ -413,48 +623,92 @@ class MapProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Set imported trail (from GPX import)
void setImportedTrail(LocationTrail trail) { void setImportedTrail(LocationTrail trail) {
_importedTrail = trail; _importedTrail = trail;
notifyListeners(); notifyListeners();
} }
/// Clear imported trail
void clearImportedTrail() { void clearImportedTrail() {
_importedTrail = null; _importedTrail = null;
notifyListeners(); notifyListeners();
} }
/// Replace current trail with imported trail
void replaceCurrentTrailWithImport(LocationTrail importedTrail) { void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
// End current trail if active
if (_currentTrail != null && _currentTrail!.isActive) { if (_currentTrail != null && _currentTrail!.isActive) {
endTrail(); endTrail();
} }
// Set imported trail as current trail
_currentTrail = importedTrail; _currentTrail = importedTrail;
_isTrailVisible = true; _isTrailVisible = true;
notifyListeners(); notifyListeners();
} }
/// Enter download area selection mode with initial bounds
void enterDownloadAreaMode(LatLngBounds initialBounds) { void enterDownloadAreaMode(LatLngBounds initialBounds) {
_isSelectingDownloadArea = true; _isSelectingDownloadArea = true;
_downloadAreaBounds = initialBounds; _downloadAreaBounds = initialBounds;
notifyListeners(); notifyListeners();
} }
/// Exit download area selection mode
void exitDownloadAreaMode() { void exitDownloadAreaMode() {
_isSelectingDownloadArea = false; _isSelectingDownloadArea = false;
_downloadAreaBounds = null; _downloadAreaBounds = null;
notifyListeners(); notifyListeners();
} }
/// Update the download area bounds (while dragging/resizing)
void updateDownloadAreaBounds(LatLngBounds bounds) { void updateDownloadAreaBounds(LatLngBounds bounds) {
_downloadAreaBounds = bounds; _downloadAreaBounds = bounds;
notifyListeners(); notifyListeners();
} }
Future<void> _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<String, dynamic>) {
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<void> _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<void> _deleteFileIfExists(String path) async {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
} }

View File

@@ -2439,6 +2439,8 @@ class MessagesProvider with ChangeNotifier {
text: message.text, text: message.text,
isSarMarker: message.isSarMarker, isSarMarker: message.isSarMarker,
sarGpsCoordinates: message.sarGpsCoordinates, sarGpsCoordinates: message.sarGpsCoordinates,
sarCustomMapPoint: message.sarCustomMapPoint,
sarCustomMapId: message.sarCustomMapId,
sarNotes: message.sarNotes, sarNotes: message.sarNotes,
sarCustomEmoji: message.sarCustomEmoji, sarCustomEmoji: message.sarCustomEmoji,
sarColorIndex: message.sarColorIndex, sarColorIndex: message.sarColorIndex,

File diff suppressed because it is too large Load Diff

View File

@@ -2020,12 +2020,19 @@ class _MessagesTabState extends State<MessagesTab> {
void _handleMessageTap(Message message) { void _handleMessageTap(Message message) {
if (widget.onNavigateToMap == null) return; if (widget.onNavigateToMap == null) return;
if (message.isSarMarker && message.sarGpsCoordinates != null) { if (message.isSarMarker) {
final mapProvider = context.read<MapProvider>(); final mapProvider = context.read<MapProvider>();
mapProvider.navigateToLocation( final marker = message.toSarMarker();
location: message.sarGpsCoordinates!, if (marker == null) {
zoom: 15.0, return;
); }
final error = mapProvider.navigateToSarMarker(marker);
if (error != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
return;
}
widget.onNavigateToMap?.call(); widget.onNavigateToMap?.call();
return; return;
} }
@@ -2034,7 +2041,16 @@ class _MessagesTabState extends State<MessagesTab> {
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}'); debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
final mapProvider = context.read<MapProvider>(); final mapProvider = context.read<MapProvider>();
final drawingProvider = context.read<DrawingProvider>(); final drawingProvider = context.read<DrawingProvider>();
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(); widget.onNavigateToMap?.call();
} }
} }

View File

@@ -152,10 +152,11 @@ class MapMarkerService {
required BuildContext context, required BuildContext context,
Function(SarMarker)? onTap, Function(SarMarker)? onTap,
double mapRotation = 0, double mapRotation = 0,
LatLng Function(LatLng point)? pointTransformer,
}) { }) {
return sarMarkers.map((marker) { return sarMarkers.map((marker) {
return Marker( return Marker(
point: marker.location, point: pointTransformer?.call(marker.location) ?? marker.location,
width: 90, width: 90,
height: 100, height: 100,
rotate: false, // Don't rotate the entire marker with map rotate: false, // Don't rotate the entire marker with map

View File

@@ -346,6 +346,9 @@ class MessageStorageService {
'isSarMarker': message.isSarMarker, 'isSarMarker': message.isSarMarker,
'sarGpsLat': message.sarGpsCoordinates?.latitude, 'sarGpsLat': message.sarGpsCoordinates?.latitude,
'sarGpsLon': message.sarGpsCoordinates?.longitude, 'sarGpsLon': message.sarGpsCoordinates?.longitude,
'sarCustomMapLat': message.sarCustomMapPoint?.latitude,
'sarCustomMapLon': message.sarCustomMapPoint?.longitude,
'sarCustomMapId': message.sarCustomMapId,
'sarNotes': message.sarNotes, 'sarNotes': message.sarNotes,
'sarCustomEmoji': message.sarCustomEmoji, 'sarCustomEmoji': message.sarCustomEmoji,
'sarColorIndex': message.sarColorIndex, 'sarColorIndex': message.sarColorIndex,
@@ -482,8 +485,19 @@ class MessageStorageService {
isSarMarker: json['isSarMarker'] as bool? ?? false, isSarMarker: json['isSarMarker'] as bool? ?? false,
sarGpsCoordinates: sarGpsCoordinates:
json['sarGpsLat'] != null && json['sarGpsLon'] != null 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, : 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?, sarNotes: json['sarNotes'] as String?,
sarCustomEmoji: json['sarCustomEmoji'] as String?, sarCustomEmoji: json['sarCustomEmoji'] as String?,
sarColorIndex: json['sarColorIndex'] as int?, sarColorIndex: json['sarColorIndex'] as int?,

View File

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

View File

@@ -1,19 +1,17 @@
import 'dart:convert'; import 'dart:convert';
import '../models/map_coordinate_space.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import 'custom_map_id.dart';
/// Parser for drawing messages transmitted over mesh network
class DrawingMessageParser { class DrawingMessageParser {
/// Drawing message prefix static const String legacyPrefix = 'D:';
static const String prefix = 'D:'; static const String customMapPrefix = 'D2:';
/// Check if message is a drawing message
static bool isDrawingMessage(String text) { 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( static MapDrawing? parseDrawingMessage(
String text, { String text, {
String? senderName, String? senderName,
@@ -24,126 +22,96 @@ class DrawingMessageParser {
} }
try { try {
// Remove prefix if (text.startsWith(customMapPrefix)) {
final jsonStr = text.substring(prefix.length); final json =
jsonDecode(text.substring(customMapPrefix.length))
as Map<String, dynamic>;
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 =
final json = jsonDecode(jsonStr) as Map<String, dynamic>; jsonDecode(text.substring(legacyPrefix.length))
as Map<String, dynamic>;
// Use ultra-compact network format parser
// Sender name and message ID come from packet metadata, not JSON
return MapDrawing.fromNetworkJson( return MapDrawing.fromNetworkJson(
json, json,
senderName: senderName, senderName: senderName,
messageId: messageId, messageId: messageId,
coordinateSpace: MapCoordinateSpace.geo,
); );
} catch (e) { } catch (_) {
return null; return null;
} }
} }
/// Create drawing message text from MapDrawing object
/// Sender will be determined from packet metadata on receiving end
static String createDrawingMessage(MapDrawing drawing) { static String createDrawingMessage(MapDrawing drawing) {
final json = drawing.toNetworkJson(); final json = drawing.toNetworkJson();
final jsonStr = jsonEncode(json).toString(); final prefix = drawing.coordinateSpace == MapCoordinateSpace.customMap
return '$prefix$jsonStr'; ? 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) { static String? getDrawingTypeDisplay(String text) {
if (!isDrawingMessage(text)) return null; final metadata = getDrawingMetadata(text);
return metadata?['type'] as String?;
try {
final jsonStr = text.substring(prefix.length);
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
final typeNum = json['t'] as int?;
if (typeNum == null) return null;
switch (typeNum) {
case 0:
return 'Line';
case 1:
return 'Rectangle';
default:
return null;
}
} catch (e) {
return null;
}
} }
/// Get color name from drawing message text
/// Returns color name like "Red", "Blue", etc., or null if parsing fails
static String? getColorName(String text) { static String? getColorName(String text) {
if (!isDrawingMessage(text)) return null; final metadata = getDrawingMetadata(text);
return metadata?['color'] as String?;
try {
final jsonStr = text.substring(prefix.length);
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
final colorIndex = json['c'] as int?;
if (colorIndex == null) return null;
// Color mapping from DrawingColor enum
const colorNames = [
'Red', // 0
'Blue', // 1
'Green', // 2
'Yellow', // 3
'Orange', // 4
'Purple', // 5
'Pink', // 6
'Cyan', // 7
];
if (colorIndex >= 0 && colorIndex < colorNames.length) {
return colorNames[colorIndex];
}
return null;
} catch (e) {
return null;
}
} }
/// Get drawing metadata for display in message bubbles
/// Returns map with type, color, and pointCount, or null if parsing fails
static Map<String, dynamic>? getDrawingMetadata(String text) { static Map<String, dynamic>? getDrawingMetadata(String text) {
if (!isDrawingMessage(text)) return null; if (!isDrawingMessage(text)) return null;
try { try {
final jsonStr = text.substring(prefix.length); final json =
final json = jsonDecode(jsonStr) as Map<String, dynamic>; jsonDecode(
text.startsWith(customMapPrefix)
? text.substring(customMapPrefix.length)
: text.substring(legacyPrefix.length),
)
as Map<String, dynamic>;
final typeNum = json['t'] as int?; final typeNum = json['t'] as int?;
final colorIndex = json['c'] as int?; final colorIndex = json['c'] as int?;
if (typeNum == null || colorIndex == null) return null; if (typeNum == null || colorIndex == null) return null;
// Get type display name
String type; String type;
int? pointCount; int? pointCount;
switch (typeNum) { switch (typeNum) {
case 0: // Line case 0:
type = 'Line'; type = 'Line';
final points = json['p'] as List?; final points = json['p'] as List?;
pointCount = points != null ? points.length ~/ 2 : null; pointCount = points != null ? points.length ~/ 2 : null;
break; break;
case 1: // Rectangle case 1:
type = 'Rectangle'; type = 'Rectangle';
pointCount = 4; // Rectangles always have 4 corners pointCount = 4;
break; break;
default: default:
return null; return null;
} }
// Get color name
const colorNames = [ 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 final color = colorIndex >= 0 && colorIndex < colorNames.length
? colorNames[colorIndex] ? colorNames[colorIndex]
@@ -153,8 +121,12 @@ class DrawingMessageParser {
'type': type, 'type': type,
'color': color, 'color': color,
'pointCount': pointCount, 'pointCount': pointCount,
'coordinateSpace': text.startsWith(customMapPrefix)
? MapCoordinateSpace.customMap.name
: MapCoordinateSpace.geo.name,
'mapId': normalizeCustomMapId(json['m'] as String?),
}; };
} catch (e) { } catch (_) {
return null; return null;
} }
} }

View File

@@ -1,216 +1,206 @@
import 'package:latlong2/latlong.dart'; import 'dart:convert';
import '../models/sar_marker.dart';
import '../models/message.dart'; 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:<emoji>:<latitude>,<longitude>:<optional_message>`
/// New format: `S:<emoji>:<colorIndex>:<latitude>,<longitude>:<optional_message>`
/// 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 { class SarMessageParser {
// Regex for new format with color index: S:emoji:colorIndex:lat,lon:notes static const String legacyPrefix = 'S:';
// Captures: emoji, colorIndex (single digit), latitude, longitude, optional message static const String customMapPrefix = 'S2:';
static final RegExp _sarPatternNew = RegExp( static final RegExp _sarPatternNew = RegExp(
r'^S:([^:]+):(\d):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)', r'^S:([^:]+):(\d):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
multiLine: false, multiLine: false,
); );
// Regex for old format (backward compatibility): S:emoji:lat,lon:notes
// Captures: emoji, latitude, longitude, optional message
static final RegExp _sarPatternOld = RegExp( static final RegExp _sarPatternOld = RegExp(
r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)', r'^S:([^:]+):(-?\d+\.?\d*),(-?\d+\.?\d*):?(.*)',
multiLine: false, multiLine: false,
); );
/// Check if a message is a SAR marker message
static bool isSarMessage(String text) { static bool isSarMessage(String text) {
// Extract just the first line for matching final lines = text.trim().split('\n');
final firstLine = text.trim().split('\n').first; final firstLine = lines.isEmpty ? text.trim() : lines.first;
return firstLine.startsWith('S:') && return firstLine.startsWith(legacyPrefix) ||
(_sarPatternNew.hasMatch(firstLine) || firstLine.startsWith(customMapPrefix);
_sarPatternOld.hasMatch(firstLine));
} }
/// 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) { static SarMarkerInfo? parse(String text) {
final trimmed = text.trim(); final trimmed = text.trim();
if (!trimmed.startsWith('S:')) return null; if (trimmed.startsWith(customMapPrefix)) {
return _parseCustomMap(trimmed);
// Extract first line (actual SAR marker) }
final firstLine = trimmed.split('\n').first; if (!trimmed.startsWith(legacyPrefix)) {
return null;
// 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;
} }
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 { try {
String emoji; final emoji = match.group(1)!;
double latitude; final colorIndex = isNewFormat ? int.parse(match.group(2)!) : null;
double longitude; final latitude = double.parse(match.group(isNewFormat ? 3 : 2)!);
String? inlineMessage; final longitude = double.parse(match.group(isNewFormat ? 4 : 3)!);
int? colorIndex; final inlineMessage = match.group(isNewFormat ? 5 : 4)?.trim();
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
if (latitude < -90 || latitude > 90) return null; if (latitude < -90 || latitude > 90) return null;
if (longitude < -180 || longitude > 180) return null; if (longitude < -180 || longitude > 180) return null;
// Validate color index if present final additionalNotes = extractNotes(text);
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
String? notes; String? notes;
if (inlineMessage != null && inlineMessage.isNotEmpty) { if (inlineMessage != null && inlineMessage.isNotEmpty) {
notes = inlineMessage; notes = inlineMessage;
} }
if (additionalNotes != null && additionalNotes.isNotEmpty) {
// Check for multi-line notes (lines after the first line)
final additionalNotes = extractNotes(text);
if (additionalNotes != null) {
notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes; notes = notes != null ? '$notes\n$additionalNotes' : additionalNotes;
} }
return SarMarkerInfo( return SarMarkerInfo(
type: markerType, type: SarMarkerType.fromEmoji(emoji),
location: location, location: LatLng(latitude, longitude),
emoji: emoji, emoji: emoji,
notes: notes, 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<String, dynamic>;
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<num>();
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; return null;
} }
} }
/// Enhance a Message with SAR marker information
static Message enhanceMessage(Message message) { static Message enhanceMessage(Message message) {
final sarInfo = parse(message.text); final sarInfo = parse(message.text);
if (sarInfo == null) return message; if (sarInfo == null) return message;
return message.copyWith( return message.copyWith(
isSarMarker: true, isSarMarker: true,
sarGpsCoordinates: sarInfo.location, sarGpsCoordinates: sarInfo.coordinateSpace == MapCoordinateSpace.geo
sarNotes: sarInfo.notes, // Extract and store notes ? sarInfo.location
sarCustomEmoji: sarInfo.emoji, // Always store emoji for type inference : null,
sarColorIndex: sarInfo.colorIndex, // Store color index 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({ static String createSarMessage({
required SarMarkerType type, required SarMarkerType type,
required LatLng location, required LatLng location,
String? notes, String? notes,
int? colorIndex, int? colorIndex,
}) { }) {
// New format: S:emoji:colorIndex:lat,lon:notes final colorIdx = colorIndex ?? 0;
final colorIdx = colorIndex ?? 0; // Default to red if not specified
final text = 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) { if (notes != null && notes.isNotEmpty) {
// Use colon-separated format for inline message
return '$text:$notes'; return '$text:$notes';
} }
return text; 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 = <String, dynamic>{
'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) { static String? extractNotes(String text) {
final trimmed = text.trim(); final trimmed = text.trim();
final lines = trimmed.split('\n'); final lines = trimmed.split('\n');
if (lines.length <= 1) return null; if (lines.length <= 1) return null;
// Everything after the first line is considered notes
return lines.sublist(1).join('\n').trim(); return lines.sublist(1).join('\n').trim();
} }
/// Validate SAR message format
static bool isValidFormat(String text) { static bool isValidFormat(String text) {
return isSarMessage(text) && parse(text) != null; return isSarMessage(text) && parse(text) != null;
} }
/// Get a user-friendly error message for invalid SAR format
static String? getFormatError(String text) { 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:"'; return 'SAR message must start with "S:"';
} }
final parts = text.trim().split(':'); final firstLine = trimmed.split('\n').first;
if (parts.length < 3) { if (firstLine == legacyPrefix) {
return 'Invalid format. Use: S:<emoji>:<latitude>,<longitude>'; return 'Invalid format for SAR marker';
}
final parts = firstLine.split(':');
if (parts.length < 2 || parts[1].isEmpty) {
return 'Missing emoji';
} }
final emoji = parts[1]; return parse(text) == null ? 'Invalid format for SAR marker' : null;
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;
} }
} }
/// Parsed SAR marker information
class SarMarkerInfo { class SarMarkerInfo {
final SarMarkerType type; final SarMarkerType type;
final LatLng location; final LatLng location;
final String emoji; final String emoji;
final String? notes; final String? notes;
final int? final int? colorIndex;
colorIndex; // Color index from standard palette (0-7), null for backward compatibility final MapCoordinateSpace coordinateSpace;
final String? mapId;
SarMarkerInfo({ SarMarkerInfo({
required this.type, required this.type,
@@ -218,10 +208,12 @@ class SarMarkerInfo {
required this.emoji, required this.emoji,
this.notes, this.notes,
this.colorIndex, this.colorIndex,
required this.coordinateSpace,
this.mapId,
}); });
@override @override
String toString() { 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)';
} }
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
import '../models/map_coordinate_space.dart';
/// Minimap preview widget for map drawings /// Minimap preview widget for map drawings
/// Renders a small 80x80px preview of a drawing on a map background /// 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; final rectDrawing = drawing as RectangleDrawing;
// Add padding (10% on each side) // Add padding (10% on each side)
final latDiff = (rectDrawing.bottomRight.latitude - rectDrawing.topLeft.latitude).abs(); final latDiff =
final lonDiff = (rectDrawing.bottomRight.longitude - rectDrawing.topLeft.longitude).abs(); (rectDrawing.bottomRight.latitude - rectDrawing.topLeft.latitude)
.abs();
final lonDiff =
(rectDrawing.bottomRight.longitude - rectDrawing.topLeft.longitude)
.abs();
final latPadding = latDiff * 0.1; final latPadding = latDiff * 0.1;
final lonPadding = lonDiff * 0.1; final lonPadding = lonDiff * 0.1;
@@ -79,6 +84,12 @@ class DrawingMinimapPreview extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bounds = _calculateBounds(); 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 <LatLng>[];
return Container( return Container(
width: 80, width: 80,
@@ -86,26 +97,27 @@ class DrawingMinimapPreview extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(color: Colors.grey.shade400, width: 1),
color: Colors.grey.shade400,
width: 1,
),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(7), borderRadius: BorderRadius.circular(7),
child: flutter_map.FlutterMap( child: flutter_map.FlutterMap(
options: flutter_map.MapOptions( options: flutter_map.MapOptions(
crs: isCustomMap
? const flutter_map.CrsSimple()
: const flutter_map.Epsg3857(),
initialCameraFit: flutter_map.CameraFit.bounds( initialCameraFit: flutter_map.CameraFit.bounds(
bounds: bounds, bounds: bounds,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
), ),
interactionOptions: const flutter_map.InteractionOptions( interactionOptions: const flutter_map.InteractionOptions(
flags: flutter_map.InteractiveFlag.none, // Disable all interactions flags:
flutter_map.InteractiveFlag.none, // Disable all interactions
), ),
), ),
children: [ children: [
// Use provided tile layer or fallback to gray background // Use provided tile layer or fallback to gray background
if (tileLayer != null) if (!isCustomMap && tileLayer != null)
tileLayer! tileLayer!
else else
Container(color: Colors.grey.shade300), Container(color: Colors.grey.shade300),
@@ -115,7 +127,7 @@ class DrawingMinimapPreview extends StatelessWidget {
flutter_map.PolylineLayer( flutter_map.PolylineLayer(
polylines: [ polylines: [
flutter_map.Polyline( flutter_map.Polyline(
points: (drawing as LineDrawing).points, points: drawingPoints,
strokeWidth: 3.0, strokeWidth: 3.0,
color: drawing.color, color: drawing.color,
), ),
@@ -125,7 +137,7 @@ class DrawingMinimapPreview extends StatelessWidget {
flutter_map.PolygonLayer( flutter_map.PolygonLayer(
polygons: [ polygons: [
flutter_map.Polygon( flutter_map.Polygon(
points: (drawing as RectangleDrawing).corners, points: drawingPoints,
color: drawing.color.withValues(alpha: 0.3), color: drawing.color.withValues(alpha: 0.3),
borderColor: drawing.color, borderColor: drawing.color,
borderStrokeWidth: 3.0, borderStrokeWidth: 3.0,

View File

@@ -8,8 +8,14 @@ import '../../l10n/app_localizations.dart';
class DrawingLayer extends StatelessWidget { class DrawingLayer extends StatelessWidget {
final List<MapDrawing> drawings; final List<MapDrawing> drawings;
final MapDrawing? previewDrawing; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -66,12 +72,16 @@ class DrawingLayer extends StatelessWidget {
/// Get points from a drawing based on its type /// Get points from a drawing based on its type
List<LatLng> _getPoints(MapDrawing drawing) { List<LatLng> _getPoints(MapDrawing drawing) {
if (drawing is LineDrawing) { if (drawing is LineDrawing) {
return drawing.points; return drawing.points.map(_transformPoint).toList();
} else if (drawing is RectangleDrawing) { } else if (drawing is RectangleDrawing) {
return drawing.corners; return drawing.corners.map(_transformPoint).toList();
} }
return []; return [];
} }
LatLng _transformPoint(LatLng point) {
return pointTransformer?.call(point) ?? point;
}
} }
/// Widget that shows drawing markers (start/end points) /// Widget that shows drawing markers (start/end points)
@@ -80,6 +90,7 @@ class DrawingMarkersLayer extends StatelessWidget {
final Function(String drawingId)? onDeleteDrawing; final Function(String drawingId)? onDeleteDrawing;
final Function(MapDrawing drawing)? onTapDrawing; final Function(MapDrawing drawing)? onTapDrawing;
final bool showDeleteButtons; final bool showDeleteButtons;
final LatLng Function(LatLng point)? pointTransformer;
const DrawingMarkersLayer({ const DrawingMarkersLayer({
super.key, super.key,
@@ -87,6 +98,7 @@ class DrawingMarkersLayer extends StatelessWidget {
this.onDeleteDrawing, this.onDeleteDrawing,
this.onTapDrawing, this.onTapDrawing,
this.showDeleteButtons = false, this.showDeleteButtons = false,
this.pointTransformer,
}); });
@override @override
@@ -144,17 +156,23 @@ class DrawingMarkersLayer extends StatelessWidget {
if (drawing is LineDrawing && drawing.points.isNotEmpty) { if (drawing is LineDrawing && drawing.points.isNotEmpty) {
// Use the middle point of the line // Use the middle point of the line
final midIndex = drawing.points.length ~/ 2; final midIndex = drawing.points.length ~/ 2;
return drawing.points[midIndex]; return _transformPoint(drawing.points[midIndex]);
} else if (drawing is RectangleDrawing) { } else if (drawing is RectangleDrawing) {
// Use the center of the rectangle // Use the center of the rectangle
return LatLng( return _transformPoint(
(drawing.topLeft.latitude + drawing.bottomRight.latitude) / 2, LatLng(
(drawing.topLeft.longitude + drawing.bottomRight.longitude) / 2, (drawing.topLeft.latitude + drawing.bottomRight.latitude) / 2,
(drawing.topLeft.longitude + drawing.bottomRight.longitude) / 2,
),
); );
} }
return null; return null;
} }
LatLng _transformPoint(LatLng point) {
return pointTransformer?.call(point) ?? point;
}
/// Show delete confirmation dialog /// Show delete confirmation dialog
void _showDeleteDialog(BuildContext context, MapDrawing drawing) { void _showDeleteDialog(BuildContext context, MapDrawing drawing) {
showDialog( showDialog(

View File

@@ -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<void> 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<CustomMapSarUpdateSheet> createState() =>
_CustomMapSarUpdateSheetState();
}
class _CustomMapSarUpdateSheetState extends State<CustomMapSarUpdateSheet> {
final SarTemplateService _templateService = SarTemplateService();
final TextEditingController _notesController = TextEditingController();
List<SarTemplate> _templates = [];
SarTemplate? _selectedTemplate;
Contact? _selectedContact;
bool _sendToAllContacts = false;
@override
void initState() {
super.initState();
_initializeTemplates();
_setDefaultDestination();
}
Future<void> _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<ContactsProvider>();
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<ContactsProvider>(
builder: (context, contactsProvider, child) {
final teamContacts = contactsProvider.chatContacts;
final roomsAndChannels =
contactsProvider.roomsAndChannels;
final destinations = <Contact>[
...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<String>(
value: _sendToAllContacts
? 'all_contacts'
: _selectedContact?.publicKeyHex,
hint: Text(
AppLocalizations.of(context)!.selectDestination,
),
dropdownColor:
colorScheme.surfaceContainerHighest,
isExpanded: true,
items: [
if (teamContacts.isNotEmpty)
DropdownMenuItem<String>(
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<String>(
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'),
),
),
),
],
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
@@ -7,6 +8,7 @@ import '../../models/message.dart';
import '../../models/sar_marker.dart'; import '../../models/sar_marker.dart';
import '../../models/sar_template.dart'; import '../../models/sar_template.dart';
import '../../models/map_drawing.dart'; import '../../models/map_drawing.dart';
import '../../models/map_coordinate_space.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
@@ -1490,7 +1492,7 @@ class _MessageBubbleState extends State<MessageBubble> {
void _navigateToDrawing(BuildContext context) { void _navigateToDrawing(BuildContext context) {
if (widget.message.drawingId == null) return; if (widget.message.drawingId == null) return;
widget.onNavigateToMap?.call(); widget.onTap?.call();
} }
void _copyDrawingCoordinates(BuildContext context) { void _copyDrawingCoordinates(BuildContext context) {
@@ -1505,21 +1507,18 @@ class _MessageBubbleState extends State<MessageBubble> {
} }
// Format coordinates based on drawing type // 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; String coordinatesText;
if (drawing is LineDrawing) { if (drawing is LineDrawing) {
coordinatesText = drawing.points coordinatesText = drawing.points.map(formatPoint).join('\n');
.map(
(p) =>
'${p.latitude.toStringAsFixed(5)}, ${p.longitude.toStringAsFixed(5)}',
)
.join('\n');
} else if (drawing is RectangleDrawing) { } else if (drawing is RectangleDrawing) {
coordinatesText = drawing.corners coordinatesText = drawing.corners.map(formatPoint).join('\n');
.map(
(p) =>
'${p.latitude.toStringAsFixed(5)}, ${p.longitude.toStringAsFixed(5)}',
)
.join('\n');
} else { } else {
ToastLogger.error(context, 'Unknown drawing type'); ToastLogger.error(context, 'Unknown drawing type');
return; return;
@@ -2294,6 +2293,72 @@ class _MessageBubbleState extends State<MessageBubble> {
], ],
), ),
), ),
] 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,
),
),
],
],
),
),
], ],
], ],
), ),

View File

@@ -786,11 +786,9 @@ packages:
meshcore_client: meshcore_client:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "../meshcore_client"
ref: f600789fac7f743b1c7db8aa24e441405413f669 relative: true
resolved-ref: f600789fac7f743b1c7db8aa24e441405413f669 source: path
url: "https://github.com/dz0ny/meshcore_client.git"
source: git
version: "0.1.0" version: "0.1.0"
meta: meta:
dependency: transitive dependency: transitive

View File

@@ -135,6 +135,8 @@ dev_dependencies:
fake_async: ^1.3.3 fake_async: ^1.3.3
dependency_overrides: dependency_overrides:
meshcore_client:
path: ../meshcore_client
# path_provider_foundation 2.6.0 pulls in package:objective_c as a native # 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 # asset. That framework has been ending up archived with a macOS platform
# slice and fails App Store validation for iOS uploads. # slice and fails App Store validation for iOS uploads.

View File

@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/map_drawing.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'; import 'package:meshcore_sar_app/utils/drawing_message_parser.dart';
void main() { void main() {
@@ -10,10 +11,7 @@ void main() {
id: 'test-123', id: 'test-123',
color: DrawingColors.palette[0], color: DrawingColors.palette[0],
createdAt: DateTime.now(), createdAt: DateTime.now(),
points: [ points: [LatLng(37.7749, -122.4194), LatLng(37.7750, -122.4195)],
LatLng(37.7749, -122.4194),
LatLng(37.7750, -122.4195),
],
); );
final message = DrawingMessageParser.createDrawingMessage(drawing); final message = DrawingMessageParser.createDrawingMessage(drawing);
@@ -59,29 +57,55 @@ void main() {
expect((parsed as LineDrawing).points.length, equals(3)); expect((parsed as LineDrawing).points.length, equals(3));
}); });
test('createDrawingMessage handles rectangle with proper string format', () { test(
final drawing = RectangleDrawing( 'createDrawingMessage handles rectangle with proper string format',
id: 'rect-789', () {
color: DrawingColors.palette[4], // orange 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<String>());
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(), createdAt: DateTime.now(),
topLeft: LatLng(45.5231, -122.6765), points: [LatLng(120, 200), LatLng(320, 450)],
bottomRight: LatLng(45.5100, -122.6600), coordinateSpace: MapCoordinateSpace.customMap,
mapId: '1234567890abcdef',
); );
final message = DrawingMessageParser.createDrawingMessage(drawing); final message = DrawingMessageParser.createDrawingMessage(drawing);
// CRITICAL: Must be pure string expect(message, startsWith('D2:'));
expect(message, isA<String>());
expect(message, startsWith('D:'));
// Should contain compact JSON format final parsed = DrawingMessageParser.parseDrawingMessage(message);
expect(message, contains('"t":')); expect(parsed, isA<LineDrawing>());
expect(message, contains('"c":')); expect(parsed!.coordinateSpace, MapCoordinateSpace.customMap);
expect(message, contains('"b":')); expect(parsed.mapId, '1234567890ab');
final parsedLine = parsed as LineDrawing;
// Must NOT contain any object representations expect(parsedLine.points.first.latitude, 120);
expect(message, isNot(contains('RectangleDrawing'))); expect(parsedLine.points.first.longitude, 200);
expect(message, isNot(contains('Instance')));
}); });
test('JSON encoding produces string with coordinates as numbers', () { test('JSON encoding produces string with coordinates as numbers', () {
@@ -89,9 +113,7 @@ void main() {
id: 'coord-test', id: 'coord-test',
color: DrawingColors.palette[1], // blue color: DrawingColors.palette[1], // blue
createdAt: DateTime.now(), createdAt: DateTime.now(),
points: [ points: [LatLng(37.77490, -122.41940)],
LatLng(37.77490, -122.41940),
],
); );
final message = DrawingMessageParser.createDrawingMessage(drawing); final message = DrawingMessageParser.createDrawingMessage(drawing);
@@ -110,17 +132,17 @@ void main() {
}); });
test('isDrawingMessage correctly identifies valid drawing messages', () { 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('S:🧑:37,-122'), isFalse);
expect(DrawingMessageParser.isDrawingMessage('Plain text'), isFalse); expect(DrawingMessageParser.isDrawingMessage('Plain text'), isFalse);
expect(DrawingMessageParser.isDrawingMessage('D:'), isTrue); expect(DrawingMessageParser.isDrawingMessage('D:'), isTrue);
}); });
test('parseDrawingMessage returns null for malformed messages', () { test('parseDrawingMessage returns null for malformed messages', () {
expect( expect(DrawingMessageParser.parseDrawingMessage('Not a drawing'), isNull);
DrawingMessageParser.parseDrawingMessage('Not a drawing'),
isNull,
);
expect( expect(
DrawingMessageParser.parseDrawingMessage('D:invalid json'), DrawingMessageParser.parseDrawingMessage('D:invalid json'),
isNull, isNull,
@@ -132,10 +154,7 @@ void main() {
id: 'roundtrip-1', id: 'roundtrip-1',
color: DrawingColors.palette[3], // yellow color: DrawingColors.palette[3], // yellow
createdAt: DateTime.now(), createdAt: DateTime.now(),
points: [ points: [LatLng(51.5074, -0.1278), LatLng(51.5075, -0.1279)],
LatLng(51.5074, -0.1278),
LatLng(51.5075, -0.1279),
],
); );
// Create message // Create message
@@ -204,9 +223,7 @@ void main() {
id: 'precision-test', id: 'precision-test',
color: DrawingColors.palette[0], color: DrawingColors.palette[0],
createdAt: DateTime.now(), createdAt: DateTime.now(),
points: [ points: [LatLng(37.774901234567, -122.419401234567)],
LatLng(37.774901234567, -122.419401234567),
],
); );
final message = DrawingMessageParser.createDrawingMessage(drawing); final message = DrawingMessageParser.createDrawingMessage(drawing);
@@ -259,8 +276,14 @@ void main() {
final lineMsg = 'D:{"t":0,"c":1,"p":[1,2,3,4]}'; final lineMsg = 'D:{"t":0,"c":1,"p":[1,2,3,4]}';
final rectMsg = 'D:{"t":1,"c":2,"b":[1,2,3,4]}'; final rectMsg = 'D:{"t":1,"c":2,"b":[1,2,3,4]}';
expect(DrawingMessageParser.getDrawingTypeDisplay(lineMsg), equals('Line')); expect(
expect(DrawingMessageParser.getDrawingTypeDisplay(rectMsg), equals('Rectangle')); DrawingMessageParser.getDrawingTypeDisplay(lineMsg),
equals('Line'),
);
expect(
DrawingMessageParser.getDrawingTypeDisplay(rectMsg),
equals('Rectangle'),
);
expect(DrawingMessageParser.getDrawingTypeDisplay('Invalid'), isNull); expect(DrawingMessageParser.getDrawingTypeDisplay('Invalid'), isNull);
}); });
@@ -322,7 +345,10 @@ void main() {
expect(message, isA<String>()); expect(message, isA<String>());
expect(message, startsWith('D:')); expect(message, startsWith('D:'));
// Should still be parseable // Should still be parseable
expect(() => DrawingMessageParser.parseDrawingMessage(message), returnsNormally); expect(
() => DrawingMessageParser.parseDrawingMessage(message),
returnsNormally,
);
}); });
}); });
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/sar_marker.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'; import 'package:meshcore_sar_app/utils/sar_message_parser.dart';
void main() { void main() {
@@ -50,7 +51,10 @@ void main() {
// CRITICAL: Coordinates must be in string form, not Object // CRITICAL: Coordinates must be in string form, not Object
expect(message, contains('40.7128')); 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 // Must NOT contain object representation
expect(message, isNot(contains('LatLng'))); expect(message, isNot(contains('LatLng')));
@@ -115,6 +119,26 @@ void main() {
expect(info.notes, equals('Large fire')); 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', () { test('round-trip: create -> parse -> create preserves format', () {
final original = SarMessageParser.createSarMessage( final original = SarMessageParser.createSarMessage(
type: SarMarkerType.stagingArea, type: SarMarkerType.stagingArea,
@@ -271,15 +295,9 @@ void main() {
isTrue, isTrue,
); );
expect( expect(SarMessageParser.isValidFormat('S:invalid:format'), isFalse);
SarMessageParser.isValidFormat('S:invalid:format'),
isFalse,
);
expect( expect(SarMessageParser.isValidFormat('Not SAR message'), isFalse);
SarMessageParser.isValidFormat('Not SAR message'),
isFalse,
);
}); });
test('getFormatError provides helpful error messages', () { test('getFormatError provides helpful error messages', () {
@@ -288,10 +306,7 @@ void main() {
contains('must start with "S:"'), contains('must start with "S:"'),
); );
expect( expect(SarMessageParser.getFormatError('S:'), contains('Invalid format'));
SarMessageParser.getFormatError('S:'),
contains('Invalid format'),
);
expect( expect(
SarMessageParser.getFormatError('S::37.7,-122.4'), SarMessageParser.getFormatError('S::37.7,-122.4'),
@@ -415,6 +430,7 @@ void main() {
emoji: '🧑', emoji: '🧑',
notes: 'Test notes', notes: 'Test notes',
colorIndex: 2, colorIndex: 2,
coordinateSpace: MapCoordinateSpace.geo,
); );
final str = info.toString(); final str = info.toString();