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 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 createState() => _MapCanvasState(); } class _MapCanvasState extends State { List 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 pair) { final first = pair[0].toDouble(); final second = pair[1].toDouble(); return widget.coordinateOrder == MapCoordinateOrder.lngLat ? LatLng(second, first) : LatLng(first, second); } List _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; points = [_toLatLng(coords)]; } else if (currentType == "LineString") { var list = widget.geometry!.coordinates as List; points = list.map((e) => _toLatLng(e as List)).toList(); } else if (currentType == "Polygon") { // Polygon coordinates: [[[…],…]] — le premier anneau est l'extérieur. var rings = widget.geometry!.coordinates as List; var ring = rings[0] as List; points = ring.map((e) => _toLatLng(e as List)).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 _ghostPoints(MapCanvasGhost ghost) { try { final coords = ghost.geometry.coordinates; if (coords == null) return []; if (ghost.geometry.type == "Point") { return [_toLatLng(coords as List)]; } final list = ghost.geometry.type == "Polygon" ? (coords as List)[0] as List : coords as List; return list.map((e) => _toLatLng(e as List)).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 _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)}'; }