Files
meshcore-sar/lib/widgets/map/polygon_draw_handler.dart
2026-04-07 14:04:45 +02:00

305 lines
9.7 KiB
Dart

import 'dart:math' show log, ln2;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../providers/offline_tiles_provider.dart';
/// Renders polygon/rectangle drawing interaction on the map.
///
/// Shows completed polygons, in-progress vertices, and handles tap events.
class PolygonDrawLayer extends StatelessWidget {
const PolygonDrawLayer({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
final polygons = <Polygon>[];
final markers = <Marker>[];
// Completed polygons
for (int i = 0; i < provider.polygons.length; i++) {
final poly = provider.polygons[i];
polygons.add(Polygon(
points: poly,
color: Colors.blue.withValues(alpha: 0.2),
borderColor: Colors.blue,
borderStrokeWidth: 2,
));
}
// In-progress polygon vertices
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.isNotEmpty) {
final verts = provider.currentVertices;
// Draw lines between vertices
if (verts.length >= 2) {
polygons.add(Polygon(
points: verts,
color: Colors.orange.withValues(alpha: 0.1),
borderColor: Colors.orange,
borderStrokeWidth: 2,
));
}
// Draw vertex markers
for (final v in verts) {
markers.add(Marker(
point: v,
width: 12,
height: 12,
child: Container(
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
));
}
}
// Rectangle first corner marker
if (provider.drawingMode == DrawingMode.rectangle &&
provider.rectangleFirstCorner != null) {
markers.add(Marker(
point: provider.rectangleFirstCorner!,
width: 14,
height: 14,
child: Container(
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.rectangle,
border: Border.all(color: Colors.white, width: 2),
),
),
));
}
return Stack(
children: [
if (polygons.isNotEmpty) PolygonLayer(polygons: polygons),
if (markers.isNotEmpty) MarkerLayer(markers: markers),
],
);
},
);
}
}
/// Renders the download progress overlay tiles.
class DownloadProgressLayer extends StatelessWidget {
const DownloadProgressLayer({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.tileOverlays.isEmpty) return const SizedBox.shrink();
final polygons = provider.tileOverlays.map((overlay) {
return Polygon(
points: [
LatLng(overlay.north, overlay.west),
LatLng(overlay.north, overlay.east),
LatLng(overlay.south, overlay.east),
LatLng(overlay.south, overlay.west),
],
color: overlay.isSkipped
? Colors.green.withValues(alpha: 0.15)
: Colors.orange.withValues(alpha: 0.15),
borderColor:
overlay.isSkipped ? Colors.green : Colors.orange,
borderStrokeWidth: 1,
);
}).toList();
return PolygonLayer(polygons: polygons);
},
);
}
}
/// Renders the coverage overlay for a cached style.
///
/// Only renders tiles at the zoom level closest to the current map zoom
/// to avoid drawing thousands of rectangles at once.
class CoverageLayer extends StatelessWidget {
final double currentZoom;
const CoverageLayer({super.key, required this.currentZoom});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.coverageOverlays.isEmpty) {
return const SizedBox.shrink();
}
// Filter to tiles near the current zoom level to keep rendering fast.
// Show the zoom level that's <= current map zoom (best visual match).
final targetZoom = currentZoom.floor();
// Group overlays by approximate zoom level based on tile size.
// A tile at zoom z covers roughly (360/2^z) degrees of longitude.
// We filter by checking if the tile width matches the target zoom.
final filtered = <Polygon>[];
for (final overlay in provider.coverageOverlays) {
// Estimate the zoom level from the tile's longitude span
final lonSpan = (overlay.east - overlay.west).abs();
if (lonSpan <= 0) continue;
final estimatedZoom = (log(360.0 / lonSpan) / ln2).round();
if (estimatedZoom == targetZoom ||
estimatedZoom == targetZoom - 1 ||
estimatedZoom == targetZoom + 1) {
filtered.add(Polygon(
points: [
LatLng(overlay.north, overlay.west),
LatLng(overlay.north, overlay.east),
LatLng(overlay.south, overlay.east),
LatLng(overlay.south, overlay.west),
],
color: Colors.blue.withValues(alpha: 0.1),
borderColor: Colors.blue.withValues(alpha: 0.4),
borderStrokeWidth: 1,
));
}
// Cap at 1000 visible tiles to avoid jank
if (filtered.length >= 1000) break;
}
if (filtered.isEmpty) return const SizedBox.shrink();
return PolygonLayer(polygons: filtered);
},
);
}
}
/// Toolbar for drawing controls.
class DrawingToolbar extends StatelessWidget {
const DrawingToolbar({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.isDownloading) return const SizedBox.shrink();
return Positioned(
right: 16,
top: 100,
child: Column(
children: [
_ToolButton(
icon: Icons.crop_square,
label: AppLocalizations.of(context)!.rectangle,
isActive: provider.drawingMode == DrawingMode.rectangle,
onTap: () => provider.setDrawingMode(
provider.drawingMode == DrawingMode.rectangle
? DrawingMode.none
: DrawingMode.rectangle,
),
),
const SizedBox(height: 8),
_ToolButton(
icon: Icons.pentagon_outlined,
label: AppLocalizations.of(context)!.polygon,
isActive: provider.drawingMode == DrawingMode.polygon,
onTap: () => provider.setDrawingMode(
provider.drawingMode == DrawingMode.polygon
? DrawingMode.none
: DrawingMode.polygon,
),
),
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.length >= 3) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.check,
label: AppLocalizations.of(context)!.finish,
isActive: false,
color: Colors.green,
onTap: () => provider.finishPolygon(),
),
],
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.isNotEmpty) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.undo,
label: AppLocalizations.of(context)!.undo,
isActive: false,
onTap: () => provider.undoLastVertex(),
),
],
if (provider.hasPolygons) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.delete_outline,
label: AppLocalizations.of(context)!.clear,
isActive: false,
color: Colors.red,
onTap: () => provider.clearPolygons(),
),
],
],
),
);
},
);
}
}
class _ToolButton extends StatelessWidget {
final IconData icon;
final String label;
final bool isActive;
final Color? color;
final VoidCallback onTap;
const _ToolButton({
required this.icon,
required this.label,
required this.isActive,
this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeColor = color ?? theme.colorScheme.primary;
return Tooltip(
message: label,
child: Material(
elevation: 2,
shape: const CircleBorder(),
color: isActive ? activeColor : theme.colorScheme.surface,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: 48,
height: 48,
child: Icon(
icon,
color: isActive
? theme.colorScheme.onPrimary
: (color ?? theme.colorScheme.onSurface),
),
),
),
),
);
}
}