512 lines
16 KiB
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)}';
|
|
}
|