manager-app/lib/Components/map_canvas.dart

512 lines
16 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:manager_api_new/api.dart';
import 'package:manager_app/Components/color_picker.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
/// Dans quel ordre les coordonnées sont stockées.
///
/// ⚠️ Les deux conventions coexistent dans le produit, et ce n'est pas un choix
/// mais un état de fait, documenté côté visiteur dans `visitapp-web/src/lib/geo.ts` :
/// les **étapes d'un parcours** sont en `[lat, lng]`, les **points d'une carte**
/// et les **annotations** suivent GeoJSON, en `[lng, lat]`. Lire un point de
/// carte comme du `[lat, lng]` place un lieu belge au large de la Somalie.
enum MapCoordinateOrder { latLng, lngLat }
/// Une géométrie affichée en fond, sans être modifiable — les *autres* points
/// de la carte pendant qu'on en place un.
class MapCanvasGhost {
const MapCanvasGhost({required this.geometry, this.color, this.label});
final GeometryDTO geometry;
final String? color;
final String? label;
}
/// La carte de travail : elle affiche une géométrie, la rend modifiable, et
/// rend la main à chaque changement.
///
/// C'est le corps de l'ancien `MapGeometryPicker`, sorti de son dialogue. Le
/// dessin ne change pas — ce sont les mêmes types (Point, LineString, Polygon),
/// le même glisser de sommet, le même encodage `[lat, lng]`. Ce qui change,
/// c'est qu'il peut vivre dans un écran, à côté de la fiche qu'il complète, et
/// montrer les autres géométries en fond.
class MapCanvas extends StatefulWidget {
const MapCanvas({
Key? key,
required this.geometry,
required this.onChanged,
this.color,
this.ghosts = const [],
this.showColorButton = true,
this.height = 420,
this.isExpanded = false,
this.onToggleExpand,
this.footerLabel,
this.coordinateOrder = MapCoordinateOrder.latLng,
}) : super(key: key);
/// Voir `MapCoordinateOrder`. Par défaut `latLng`, l'ordre qu'écrivait déjà
/// l'éditeur de géométrie — les appelants qui manipulent des points de carte
/// ou des annotations passent `lngLat`.
final MapCoordinateOrder coordinateOrder;
final GeometryDTO? geometry;
final String? color;
/// Appelé à chaque modification du tracé — il n'y a pas de bouton
/// « Enregistrer » dans un canevas : c'est l'écran qui enregistre.
final void Function(GeometryDTO geometry, String color) onChanged;
final List<MapCanvasGhost> ghosts;
final bool showColorButton;
final double height;
final bool isExpanded;
final VoidCallback? onToggleExpand;
/// Ce qu'on est en train de modifier, affiché en pied de carte pour qu'aucun
/// clic ne soit ambigu.
final String? footerLabel;
@override
State<MapCanvas> createState() => _MapCanvasState();
}
class _MapCanvasState extends State<MapCanvas> {
List<LatLng> points = [];
String currentType = "Point";
Color selectedColor = kPrimaryColor;
final MapController _mapController = MapController();
final GlobalKey _mapKey = GlobalKey();
@override
void initState() {
super.initState();
_adoptWidgetValues();
}
@override
void didUpdateWidget(MapCanvas oldWidget) {
super.didUpdateWidget(oldWidget);
// Changer de point sélectionné doit recharger le tracé affiché.
if (widget.geometry != oldWidget.geometry || widget.color != oldWidget.color) {
_adoptWidgetValues();
}
}
void _adoptWidgetValues() {
points = [];
currentType = widget.geometry?.type ?? "Point";
if (widget.geometry != null) _parseGeometry();
selectedColor = parseGeometryColor(widget.color) ?? kPrimaryColor;
}
/// Une paire brute → `LatLng`, dans l'ordre déclaré par l'appelant.
LatLng _toLatLng(List<dynamic> pair) {
final first = pair[0].toDouble();
final second = pair[1].toDouble();
return widget.coordinateOrder == MapCoordinateOrder.lngLat
? LatLng(second, first)
: LatLng(first, second);
}
List<double> _fromLatLng(LatLng point) {
return widget.coordinateOrder == MapCoordinateOrder.lngLat
? [point.longitude, point.latitude]
: [point.latitude, point.longitude];
}
void _parseGeometry() {
if (widget.geometry?.coordinates == null) return;
try {
if (currentType == "Point") {
var coords = widget.geometry!.coordinates as List<dynamic>;
points = [_toLatLng(coords)];
} else if (currentType == "LineString") {
var list = widget.geometry!.coordinates as List<dynamic>;
points = list.map((e) => _toLatLng(e as List<dynamic>)).toList();
} else if (currentType == "Polygon") {
// Polygon coordinates: [[[…],…]] — le premier anneau est l'extérieur.
var rings = widget.geometry!.coordinates as List<dynamic>;
var ring = rings[0] as List<dynamic>;
points = ring.map((e) => _toLatLng(e as List<dynamic>)).toList();
if (points.length > 1 && points.first == points.last) {
points.removeLast();
}
}
} catch (e) {
print("Error parsing geometry: $e");
}
}
GeometryDTO _buildGeometry() {
if (currentType == "Point") {
return GeometryDTO(
type: "Point",
coordinates: points.isNotEmpty ? _fromLatLng(points[0]) : null,
);
} else if (currentType == "Polygon") {
return GeometryDTO(
type: "Polygon",
coordinates: [points.map(_fromLatLng).toList()],
);
}
return GeometryDTO(
type: currentType,
coordinates: points.map(_fromLatLng).toList(),
);
}
void _emit() {
widget.onChanged(_buildGeometry(), geometryColorToHex(selectedColor));
}
void _handleTap(TapPosition tapPosition, LatLng latLng) {
setState(() {
if (currentType == "Point") {
points = [latLng];
} else {
points.add(latLng);
}
});
_emit();
}
void _handleDrag(int index, DragUpdateDetails details) {
if (index < 0 || index >= points.length) return;
final RenderBox? mapBox =
_mapKey.currentContext?.findRenderObject() as RenderBox?;
if (mapBox == null) return;
final Offset localOffset = mapBox.globalToLocal(details.globalPosition);
try {
LatLng newLatLng = _mapController.camera
.pointToLatLng(math.Point(localOffset.dx, localOffset.dy));
setState(() => points[index] = newLatLng);
_emit();
} catch (e) {
print("Error dragging point: $e");
}
}
List<LatLng> _ghostPoints(MapCanvasGhost ghost) {
try {
final coords = ghost.geometry.coordinates;
if (coords == null) return [];
if (ghost.geometry.type == "Point") {
return [_toLatLng(coords as List<dynamic>)];
}
final list = ghost.geometry.type == "Polygon"
? (coords as List<dynamic>)[0] as List<dynamic>
: coords as List<dynamic>;
return list.map((e) => _toLatLng(e as List<dynamic>)).toList();
} catch (e) {
return [];
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return SizedBox(
height: widget.height,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: kLine),
borderRadius: BorderRadius.circular(kRadiusCard),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
FlutterMap(
key: _mapKey,
mapController: _mapController,
options: MapOptions(
initialCenter:
points.isNotEmpty ? points[0] : LatLng(50.429333, 4.891434),
initialZoom: 14,
onTap: _handleTap,
),
children: [
TileLayer(
urlTemplate: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
),
// Les autres géométries, en fond : c'est l'information qui
// manquait quand le tracé se faisait dans une modale.
for (final ghost in widget.ghosts) ..._ghostLayers(ghost),
if (currentType == "Polygon" && points.length >= 3)
PolygonLayer(
polygons: [
Polygon(
points: points,
color: selectedColor.withValues(alpha: 0.3),
borderStrokeWidth: 2,
borderColor: selectedColor,
),
],
),
if (currentType == "LineString" && points.length >= 2)
PolylineLayer(
polylines: [
Polyline(
points: points, color: selectedColor, strokeWidth: 4),
],
),
MarkerLayer(
markers: points.asMap().entries.map((entry) {
return Marker(
point: entry.value,
width: 30,
height: 30,
child: GestureDetector(
onPanUpdate: (details) =>
_handleDrag(entry.key, details),
child: Container(
decoration: BoxDecoration(
color: kWhite,
shape: BoxShape.circle,
border: Border.all(color: selectedColor, width: 2),
boxShadow: const [
BoxShadow(
blurRadius: 4,
color: Colors.black26,
offset: Offset(0, 2))
],
),
child: Center(
child: Icon(Icons.circle,
color: selectedColor, size: 14),
),
),
),
);
}).toList(),
),
],
),
Positioned(top: kSpace3, left: kSpace3, child: _buildTools(l)),
if (widget.onToggleExpand != null)
Positioned(
top: kSpace3,
right: kSpace3,
child: _iconButton(
widget.isExpanded ? Icons.close_fullscreen : Icons.open_in_full,
widget.isExpanded ? l.geometryCollapse : l.geometryExpand,
widget.onToggleExpand!,
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildFooter(l),
),
],
),
),
);
}
List<Widget> _ghostLayers(MapCanvasGhost ghost) {
final ghostPoints = _ghostPoints(ghost);
if (ghostPoints.isEmpty) return [];
final color = (parseGeometryColor(ghost.color) ?? kInk3)
.withValues(alpha: 0.45);
if (ghost.geometry.type == "Polygon" && ghostPoints.length >= 3) {
return [
PolygonLayer(polygons: [
Polygon(
points: ghostPoints,
color: color.withValues(alpha: 0.15),
borderStrokeWidth: 1.5,
borderColor: color,
)
])
];
}
if (ghost.geometry.type == "LineString" && ghostPoints.length >= 2) {
return [
PolylineLayer(polylines: [
Polyline(points: ghostPoints, color: color, strokeWidth: 3)
])
];
}
return [
MarkerLayer(
markers: ghostPoints
.map((p) => Marker(
point: p,
width: 20,
height: 20,
child: Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: kWhite, width: 1.5),
),
),
))
.toList(),
)
];
}
Widget _buildTools(AppLocalizations l) {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: kSurface,
border: Border.all(color: kLine),
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_typeButton("Point", Icons.location_on, l.geometryTypePoint),
_typeButton("LineString", Icons.show_chart, l.geometryTypeLine),
_typeButton("Polygon", Icons.pentagon, l.geometryTypePolygon),
const SizedBox(width: kSpace2),
if (widget.showColorButton)
Tooltip(
message: l.colorHexLabel,
child: InkWell(
borderRadius: BorderRadius.circular(kRadiusInput - 1),
onTap: () => showColorPicker(selectedColor, (Color color) {
setState(() => selectedColor = color);
_emit();
}, context),
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: selectedColor,
border: Border.all(color: kLine),
borderRadius: BorderRadius.circular(kRadiusInput - 1),
),
),
),
),
_iconButton(Icons.delete_outline, l.geometryClear, () {
setState(() => points.clear());
_emit();
}),
],
),
);
}
Widget _typeButton(String type, IconData icon, String label) {
final isSelected = currentType == type;
return Tooltip(
message: label,
child: InkWell(
borderRadius: BorderRadius.circular(kRadiusInput - 1),
onTap: () {
setState(() {
currentType = type;
if (currentType == "Point" && points.length > 1) {
points = [points[0]];
}
});
_emit();
},
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: isSelected ? kPrimaryColor : Colors.transparent,
borderRadius: BorderRadius.circular(kRadiusInput - 1),
),
child: Icon(icon, size: 15, color: isSelected ? kWhite : kInk2),
),
),
);
}
Widget _iconButton(IconData icon, String tooltip, VoidCallback onPressed) {
return Tooltip(
message: tooltip,
child: Material(
color: kSurface,
borderRadius: BorderRadius.circular(kRadiusInput),
child: InkWell(
borderRadius: BorderRadius.circular(kRadiusInput),
onTap: onPressed,
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(
border: Border.all(color: kLine),
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: Icon(icon, size: 15, color: kInk2),
),
),
),
);
}
Widget _buildFooter(AppLocalizations l) {
final position = points.isEmpty
? _hint(l)
: "${points[0].latitude.toStringAsFixed(5)}, ${points[0].longitude.toStringAsFixed(5)}";
return Container(
padding: const EdgeInsets.symmetric(
horizontal: kSpace4, vertical: kSpace2),
decoration: const BoxDecoration(
color: kSurface,
border: Border(top: BorderSide(color: kLine)),
),
child: Row(
children: [
Expanded(
child: Text(position,
maxLines: 1, overflow: TextOverflow.ellipsis, style: kTextHint),
),
if (widget.footerLabel != null)
Text(widget.footerLabel!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: kPrimaryColor)),
],
),
);
}
String _hint(AppLocalizations l) {
switch (currentType) {
case "LineString":
return l.geometryHintLine;
case "Polygon":
return l.geometryHintPolygon;
default:
return l.geometryHintPoint;
}
}
}
Color? parseGeometryColor(String? hex) {
if (hex == null) return null;
try {
var value = hex.replaceFirst('#', '');
if (value.length == 6) value = 'FF$value';
return Color(int.parse(value, radix: 16));
} catch (e) {
return null;
}
}
String geometryColorToHex(Color color) {
return '#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}';
}