580 lines
18 KiB
Dart
580 lines
18 KiB
Dart
// lib/widgets/shared_map_widget.dart
|
|
import 'dart:math' as math;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
|
|
import '../services/gnss/gnss_service.dart';
|
|
import '../services/coord_converter_service.dart';
|
|
import '../pages/map_survey/presentations/controllers/map_survey_controller.dart';
|
|
|
|
// ─── SharedMapWidget ──────────────────────────────────────────────────────────
|
|
|
|
class SharedMapWidget extends StatefulWidget {
|
|
/// Extra flutter_map rétegek (pl. PolylineLayer, PolygonLayer).
|
|
final List<Widget> extraLayers;
|
|
|
|
/// Külső MapController — ha az oldal saját maga is mozgatja a térképet.
|
|
final MapController? mapController;
|
|
|
|
/// Hosszú nyomás callback (terepbejárás pont hozzáadáshoz).
|
|
final void Function(TapPosition, LatLng)? onLongPress;
|
|
|
|
/// Megjelenítendő vezérlők.
|
|
final MapControls controls;
|
|
|
|
/// Kezdeti zoom szint.
|
|
final double initialZoom;
|
|
|
|
const SharedMapWidget({
|
|
super.key,
|
|
this.extraLayers = const [],
|
|
this.mapController,
|
|
this.onLongPress,
|
|
this.controls = const MapControls(),
|
|
this.initialZoom = 18.0,
|
|
});
|
|
|
|
@override
|
|
State<SharedMapWidget> createState() => _SharedMapWidgetState();
|
|
}
|
|
|
|
class _SharedMapWidgetState extends State<SharedMapWidget> {
|
|
late final MapController _mapController;
|
|
|
|
// Reaktív belső állapot
|
|
final _isFollowing = true.obs; // GPS követés be/ki
|
|
final _currentZoom = 18.0.obs;
|
|
final _isNorthUp = true.obs; // forgó térkép vs. É-up
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_mapController = widget.mapController ?? MapController();
|
|
_currentZoom.value = widget.initialZoom;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Csak akkor disposoljuk, ha belső controller
|
|
if (widget.mapController == null) {
|
|
_mapController.dispose();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
// ── GPS pozíció követése ────────────────────────────────────────────
|
|
|
|
void _onPositionChanged(MapCamera camera, bool hasGesture) {
|
|
_currentZoom.value = camera.zoom;
|
|
// Ha a felhasználó manuálisan mozgatja → kikapcsol a követés
|
|
if (hasGesture && _isFollowing.value) {
|
|
_isFollowing.value = false;
|
|
}
|
|
}
|
|
|
|
void _centerOnPosition() {
|
|
final gnss = GnssService.to;
|
|
if (gnss.latitude.value == 0) return;
|
|
_mapController.move(
|
|
LatLng(gnss.latitude.value, gnss.longitude.value),
|
|
_currentZoom.value,
|
|
);
|
|
_isFollowing.value = true;
|
|
}
|
|
|
|
void _zoomIn() =>
|
|
_mapController.move(_mapController.camera.center, _currentZoom.value + 1);
|
|
|
|
void _zoomOut() =>
|
|
_mapController.move(_mapController.camera.center, _currentZoom.value - 1);
|
|
|
|
void _resetNorth() {
|
|
_mapController.rotate(0);
|
|
_isNorthUp.value = true;
|
|
}
|
|
|
|
// ── GPS stream → térkép mozgatás ───────────────────────────────────
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Obx(() {
|
|
final gnss = GnssService.to;
|
|
final lat = gnss.latitude.value;
|
|
final lon = gnss.longitude.value;
|
|
|
|
if (_isFollowing.value && lat != 0 && lon != 0) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_mapController.move(LatLng(lat, lon), _currentZoom.value);
|
|
});
|
|
}
|
|
|
|
return Stack(
|
|
children: [
|
|
// ── Térkép ────────────────────────────────────────────────
|
|
FlutterMap(
|
|
mapController: _mapController,
|
|
options: MapOptions(
|
|
initialCenter:
|
|
lat != 0 ? LatLng(lat, lon) : const LatLng(47.5, 19.0),
|
|
initialZoom: widget.initialZoom,
|
|
maxZoom: 25,
|
|
minZoom: 3,
|
|
onLongPress: widget.onLongPress,
|
|
onPositionChanged: _onPositionChanged,
|
|
interactionOptions: const InteractionOptions(
|
|
flags: InteractiveFlag.all,
|
|
),
|
|
),
|
|
children: [
|
|
// 1. Alaptérkép
|
|
TileLayer(
|
|
urlTemplate:
|
|
'http://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}',
|
|
subdomains: const ['mt0', 'mt1', 'mt2', 'mt3'],
|
|
maxNativeZoom: 18,
|
|
),
|
|
// 2. Extra rétegek (terepbejárás elemei)
|
|
...widget.extraLayers,
|
|
// 3. Bemért pontok
|
|
if (Get.isRegistered<MapSurveyController>())
|
|
Obx(() => MarkerLayer(
|
|
markers: _buildMeasuredPointMarkers(),
|
|
)),
|
|
// 4. Kitűzési célpont + vonal
|
|
if (Get.isRegistered<MapSurveyController>())
|
|
Obx(() => _buildStakeoutLayer(lat, lon)),
|
|
// 5. GPS pozíció
|
|
Obx(() {
|
|
final lat = GnssService.to.latitude.value;
|
|
final lon = GnssService.to.longitude.value;
|
|
return MarkerLayer(
|
|
markers: lat == 0 && lon == 0
|
|
? []
|
|
: [_buildCurrentPositionMarker(lat, lon)],
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
|
|
// ── Vezérlők ──────────────────────────────────────────────
|
|
_MapControlsOverlay(
|
|
controls: widget.controls,
|
|
isFollowing: _isFollowing,
|
|
isNorthUp: _isNorthUp,
|
|
currentZoom: _currentZoom,
|
|
onZoomIn: _zoomIn,
|
|
onZoomOut: _zoomOut,
|
|
onCenterOnGps: _centerOnPosition,
|
|
onResetNorth: _resetNorth,
|
|
),
|
|
|
|
// ── Zoom szint jelzés (opcionális) ────────────────────────
|
|
if (widget.controls.showZoomLevel)
|
|
Positioned(
|
|
bottom: 8,
|
|
left: 8,
|
|
child: Obx(() => _ZoomLabel(_currentZoom.value)),
|
|
),
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
// ── Marker builder metódusok ────────────────────────────────────────
|
|
|
|
List<Marker> _buildMeasuredPointMarkers() {
|
|
if (!Get.isRegistered<MapSurveyController>()) return [];
|
|
return MapSurveyController.to.measuredPoints1
|
|
.map((point) => Marker(
|
|
point: LatLng(point.latitude, point.longitude),
|
|
width: 100,
|
|
height: 56,
|
|
alignment: Alignment.bottomCenter,
|
|
child: _LabeledMarker(
|
|
label: point.name,
|
|
icon: Icons.location_on,
|
|
color: Colors.blue,
|
|
),
|
|
))
|
|
.toList();
|
|
}
|
|
|
|
Widget _buildStakeoutLayer(double lat, double lon) {
|
|
if (!Get.isRegistered<MapSurveyController>())
|
|
return const SizedBox.shrink();
|
|
final ctrl = MapSurveyController.to;
|
|
if (ctrl.mode.value != MapSurveyMode.stakeout ||
|
|
ctrl.targetEovY.value == 0) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final wgs = CoordConverterService.to
|
|
.eovToWgsPoint(ctrl.targetEovY.value, ctrl.targetEovX.value);
|
|
final targetLatLng = LatLng(wgs.y, wgs.x);
|
|
|
|
return Stack(children: [
|
|
// Szaggatott vonal
|
|
PolylineLayer(polylines: [
|
|
Polyline(
|
|
points: [LatLng(lat, lon), targetLatLng],
|
|
color: Colors.orange.withOpacity(0.85),
|
|
strokeWidth: 2.5,
|
|
),
|
|
]),
|
|
// Célpont marker
|
|
MarkerLayer(markers: [
|
|
Marker(
|
|
point: targetLatLng,
|
|
width: 130,
|
|
height: 72,
|
|
alignment: Alignment.bottomCenter,
|
|
child: _LabeledMarker(
|
|
label: ctrl.targetName.value,
|
|
icon: Icons.flag,
|
|
color: Colors.orange,
|
|
activeColor: ctrl.isOnTarget ? Colors.green : null,
|
|
sublabel: ctrl.isOnTarget
|
|
? '✓ Célponton'
|
|
: '${ctrl.distanceToTarget.toStringAsFixed(3)} m',
|
|
),
|
|
),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
Marker _buildCurrentPositionMarker(double lat, double lon) {
|
|
final color = switch (GnssService.to.gpsQuality.value) {
|
|
4 => Colors.green,
|
|
5 => Colors.lightGreen,
|
|
2 => Colors.blue,
|
|
1 => Colors.orange,
|
|
_ => Colors.grey,
|
|
};
|
|
return Marker(
|
|
point: LatLng(lat, lon),
|
|
width: 24,
|
|
height: 24,
|
|
child: _PulsingDot(color: color),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Vezérlők konfigurációja ──────────────────────────────────────────────────
|
|
|
|
class MapControls {
|
|
final bool showZoomButtons;
|
|
final bool showFollowButton;
|
|
final bool showNorthButton;
|
|
final bool showZoomLevel;
|
|
final bool showCompass;
|
|
|
|
const MapControls({
|
|
this.showZoomButtons = true,
|
|
this.showFollowButton = true,
|
|
this.showNorthButton = true,
|
|
this.showZoomLevel = true,
|
|
this.showCompass = false,
|
|
});
|
|
|
|
/// Terepbejárás módhoz — nincs follow (rajzolás közben szabad mozgás)
|
|
const MapControls.fieldTrip()
|
|
: showZoomButtons = true,
|
|
showFollowButton = false,
|
|
showNorthButton = true,
|
|
showZoomLevel = true,
|
|
showCompass = false;
|
|
|
|
/// Navigáció módhoz — minden vezérlő
|
|
const MapControls.navigation()
|
|
: showZoomButtons = true,
|
|
showFollowButton = true,
|
|
showNorthButton = true,
|
|
showZoomLevel = false,
|
|
showCompass = true;
|
|
|
|
/// Minimális — csak zoom
|
|
const MapControls.minimal()
|
|
: showZoomButtons = true,
|
|
showFollowButton = false,
|
|
showNorthButton = false,
|
|
showZoomLevel = false,
|
|
showCompass = false;
|
|
}
|
|
|
|
// ─── Vezérlők overlay ────────────────────────────────────────────────────────
|
|
|
|
class _MapControlsOverlay extends StatelessWidget {
|
|
final MapControls controls;
|
|
final RxBool isFollowing;
|
|
final RxBool isNorthUp;
|
|
final RxDouble currentZoom;
|
|
final VoidCallback onZoomIn;
|
|
final VoidCallback onZoomOut;
|
|
final VoidCallback onCenterOnGps;
|
|
final VoidCallback onResetNorth;
|
|
|
|
const _MapControlsOverlay({
|
|
required this.controls,
|
|
required this.isFollowing,
|
|
required this.isNorthUp,
|
|
required this.currentZoom,
|
|
required this.onZoomIn,
|
|
required this.onZoomOut,
|
|
required this.onCenterOnGps,
|
|
required this.onResetNorth,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Positioned(
|
|
right: 10,
|
|
bottom: 80, // BottomNav felett
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// ── Iránytű / É-ra forgat ─────────────────────────────
|
|
if (controls.showNorthButton)
|
|
Obx(() => _ControlButton(
|
|
icon: Icons.navigation,
|
|
tooltip: 'Észak felfelé',
|
|
active: isNorthUp.value,
|
|
// A gomb elfordul ahogy a térkép forog — vizuális jelzés
|
|
child: Transform.rotate(
|
|
angle: 0,
|
|
child: const Icon(Icons.navigation, size: 20),
|
|
),
|
|
onTap: onResetNorth,
|
|
)),
|
|
|
|
if (controls.showNorthButton) const SizedBox(height: 6),
|
|
|
|
// ── GPS követés ───────────────────────────────────────
|
|
if (controls.showFollowButton)
|
|
Obx(() => _ControlButton(
|
|
tooltip: isFollowing.value
|
|
? 'GPS követés aktív'
|
|
: 'GPS követés kikapcsolva',
|
|
active: isFollowing.value,
|
|
icon:
|
|
isFollowing.value ? Icons.gps_fixed : Icons.gps_not_fixed,
|
|
onTap: onCenterOnGps,
|
|
)),
|
|
|
|
if (controls.showFollowButton) const SizedBox(height: 6),
|
|
|
|
// ── Zoom gombok ───────────────────────────────────────
|
|
if (controls.showZoomButtons) ...[
|
|
_ControlButton(
|
|
icon: Icons.add,
|
|
tooltip: 'Nagyítás',
|
|
onTap: onZoomIn,
|
|
),
|
|
const SizedBox(height: 2),
|
|
_ControlButton(
|
|
icon: Icons.remove,
|
|
tooltip: 'Kicsinyítés',
|
|
onTap: onZoomOut,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ControlButton extends StatelessWidget {
|
|
final IconData? icon;
|
|
final Widget? child;
|
|
final String tooltip;
|
|
final bool active;
|
|
final VoidCallback onTap;
|
|
|
|
const _ControlButton({
|
|
this.icon,
|
|
this.child,
|
|
required this.tooltip,
|
|
this.active = false,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: Material(
|
|
color: active
|
|
? Theme.of(context).colorScheme.primaryContainer
|
|
: isDark
|
|
? Colors.grey.shade800.withOpacity(0.9)
|
|
: Colors.white.withOpacity(0.9),
|
|
borderRadius: BorderRadius.circular(8),
|
|
elevation: 3,
|
|
shadowColor: Colors.black38,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: onTap,
|
|
child: SizedBox(
|
|
width: 42,
|
|
height: 42,
|
|
child: Center(
|
|
child: child ??
|
|
Icon(
|
|
icon,
|
|
size: 20,
|
|
color: active
|
|
? Theme.of(context).colorScheme.primary
|
|
: isDark
|
|
? Colors.white70
|
|
: Colors.black87,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Zoom szint label ─────────────────────────────────────────────────────────
|
|
|
|
class _ZoomLabel extends StatelessWidget {
|
|
final double zoom;
|
|
const _ZoomLabel(this.zoom);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black54,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
'Z ${zoom.toStringAsFixed(1)}',
|
|
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Feliratozott marker ──────────────────────────────────────────────────────
|
|
|
|
class _LabeledMarker extends StatelessWidget {
|
|
final String label;
|
|
final IconData icon;
|
|
final Color color;
|
|
final Color? activeColor;
|
|
final String? sublabel;
|
|
|
|
const _LabeledMarker({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.color,
|
|
this.activeColor,
|
|
this.sublabel,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = activeColor ?? color;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: c,
|
|
borderRadius: BorderRadius.circular(6),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.3),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
)
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600),
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
),
|
|
if (sublabel != null)
|
|
Text(sublabel!,
|
|
style: TextStyle(
|
|
color: Colors.white.withOpacity(0.9), fontSize: 9)),
|
|
],
|
|
),
|
|
),
|
|
Container(width: 2, height: 6, color: c),
|
|
Icon(icon, color: c, size: 22, shadows: const [
|
|
Shadow(color: Colors.black45, blurRadius: 4, offset: Offset(0, 2))
|
|
]),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Pulzáló GPS pont ─────────────────────────────────────────────────────────
|
|
|
|
class _PulsingDot extends StatefulWidget {
|
|
final Color color;
|
|
const _PulsingDot({required this.color});
|
|
|
|
@override
|
|
State<_PulsingDot> createState() => _PulsingDotState();
|
|
}
|
|
|
|
class _PulsingDotState extends State<_PulsingDot>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _ctrl;
|
|
late Animation<double> _scale;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ctrl = AnimationController(
|
|
vsync: this, duration: const Duration(milliseconds: 1000))
|
|
..repeat(reverse: true);
|
|
_scale = Tween(begin: 0.8, end: 1.2)
|
|
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_ctrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => ScaleTransition(
|
|
scale: _scale,
|
|
child: Container(
|
|
width: 18,
|
|
height: 18,
|
|
decoration: BoxDecoration(
|
|
color: widget.color,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 2),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: widget.color.withOpacity(0.5),
|
|
blurRadius: 8,
|
|
spreadRadius: 2)
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|