Files
MobilApp/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart
T

686 lines
22 KiB
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 'package:terepi_seged/models/vibro_nav_route.dart';
import 'package:terepi_seged/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart';
import 'package:terepi_seged/pages/vibro_nav/presentation/views/vibro_nav_routes_view.dart';
import 'package:terepi_seged/services/gnss/gnss_connection.dart';
import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/vechicle_identity_service.dart';
import 'package:terepi_seged/widgets/gnss_device_picker_dialog.dart';
import 'package:terepi_seged/widgets/map/animated_map_follow.dart';
class VibroNavView extends GetView<VibroNavController> {
const VibroNavView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
// Tömör AppBar, NEM lóg a térkép fölé félig átlátszóan — az előző
// verzióban ez tette rosszul láthatóvá az eszköz-választó gombot.
appBar: AppBar(
// title: const Text('Navigáció'),
title: Obx(() => Text(
'N(${controller.sourcePoints.length}/${controller.visibleGeoSourcePoints.length})',
style: const TextStyle(fontSize: 11),
)),
actions: [
IconButton(
icon: const Icon(Icons.alt_route),
tooltip: 'Útvonalak kezelése',
onPressed: VibroNavRoutesView.open,
),
Obx(() => IconButton(
icon: Icon(controller.showReservePoints.value
? Icons.circle_outlined
: Icons.circle),
tooltip: controller.showReservePoints.value
? 'Tartalék pontok elrejtése'
: 'Tartalék pontok megjelenítése',
onPressed: controller.toggleReservePoints,
)),
Obx(() => IconButton(
icon: Icon(controller.trackUpEnabled.value
? Icons.explore
: Icons.explore_off_outlined),
tooltip: controller.trackUpEnabled.value
? 'Forgó térkép (track-up) — kikapcsolás'
: 'Észak-fent nézet — track-up bekapcsolása',
onPressed: controller.toggleTrackUp,
)),
IconButton(
icon: const Icon(Icons.bluetooth_connected),
tooltip: 'Gyors újracsatlakozás az utolsó GNSS-vevőhöz',
onPressed: _quickReconnect,
),
IconButton(
icon: const Icon(Icons.settings_input_antenna),
tooltip: 'GNSS-eszköz kiválasztása',
onPressed: () => GnssDevicePickerDialog.show(),
),
],
),
body: Obx(() {
if (!controller.mapReady.value) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('Pozíció lekérése…'),
],
),
);
}
return Stack(
children: [
const _NavMap(),
Obx(() {
final gnss = GnssService.to;
final target =
controller.followMode.value && controller.hasPosition.value
? LatLng(gnss.latitude.value, gnss.longitude.value)
: null;
final heading = controller.trackUpEnabled.value
? controller.travelHeading.value
: null;
return AnimatedMapFollow(
mapController: controller.mapController,
target: target,
heading: heading,
);
}),
// EGYETLEN sor, egymás mellett — nincs két, egymást átfedő
// Positioned widget, tehát fizikailag nem takarhatják egymást.
Positioned(
left: 8,
right: 8,
top: 8,
child: Row(
children: [
const Expanded(child: _VehicleBadge()),
const SizedBox(width: 8),
const _GnssStatusBadge(),
const SizedBox(width: 8),
const _CompassWidget(),
],
),
),
Positioned(
left: 12,
right: 12,
bottom: 16,
child: const _PositionHud(),
),
Obx(() => controller.followMode.value
? const SizedBox.shrink()
: Positioned(
right: 16,
bottom: 170,
child: FloatingActionButton(
heroTag: 'recenter',
onPressed: controller.resumeFollowing,
child: const Icon(Icons.my_location),
),
)),
],
);
}),
);
}
void _quickReconnect() {
final device = GnssDeviceService.to.selectedDevice.value;
if (device == null) {
Get.snackbar('Nincs korábbi GNSS-eszköz',
'Előbb válassz ki egyet a jobb felső ikonnal.',
snackPosition: SnackPosition.BOTTOM);
return;
}
Get.snackbar('Újracsatlakozás', '„${device.name}" — folyamatban…',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2));
GnssService.to.reconnect();
}
}
Color _qualityColor(int quality) =>
quality >= 4 ? Colors.green : (quality > 0 ? Colors.orange : Colors.grey);
/// A térkép ITT, önálló widgetként él: a build-je Obx-en kívül fut, így
/// GNSS-frissítéskor a FlutterMap és a TileLayer NEM épül újra. Csak az
/// alábbi, saját Obx-ű rétegek frissülnek.
class _NavMap extends StatelessWidget {
const _NavMap();
@override
Widget build(BuildContext context) {
final controller = VibroNavController.to;
final gnss = GnssService.to;
// Csak a KEZDŐ középpont — a követést az AnimatedMapFollow végzi.
final center = controller.hasPosition.value
? LatLng(gnss.latitude.value, gnss.longitude.value)
: const LatLng(47.5, 19.05);
return FlutterMap(
mapController: controller.mapController,
options: MapOptions(
initialCenter: center,
initialZoom: 17,
maxZoom: 22,
onPositionChanged: (position, hasGesture) {
if (hasGesture && controller.followMode.value) {
// Build közben ne írjunk Rx-et — képkocka végére ütemezzük.
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.followMode.value = false;
});
}
},
),
children: [
TileLayer(
urlTemplate: 'http://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}',
subdomains: const ['mt0', 'mt1', 'mt2', 'mt3'],
maxNativeZoom: 18),
const _SourcePointsLayer(),
const _RoutesLayer(),
Obx(() => controller.trail.length > 1
? PolylineLayer(polylines: [
Polyline(
points: controller.trail.toList(),
color: Colors.blueAccent.withOpacity(0.75),
strokeWidth: 4,
),
])
: const SizedBox.shrink()),
Obx(() {
if (!controller.hasPosition.value) return const SizedBox.shrink();
final color = _qualityColor(gnss.gpsQuality.value);
return CircleLayer(circles: [
CircleMarker(
point: LatLng(gnss.latitude.value, gnss.longitude.value),
radius:
math.max(gnss.latitudeError.value, gnss.longitudeError.value),
useRadiusInMeter: true,
color: color.withOpacity(0.12),
borderColor: color.withOpacity(0.4),
borderStrokeWidth: 1,
),
]);
}),
Obx(() {
if (!controller.hasPosition.value) return const SizedBox.shrink();
final color = _qualityColor(gnss.gpsQuality.value);
return MarkerLayer(markers: [
Marker(
point: LatLng(gnss.latitude.value, gnss.longitude.value),
width: 60,
height: 60,
child: _PulsingPositionMarker(color: color),
),
]);
}),
],
);
}
}
/// A forráspontok rétege — csak a `geoSourcePoints` és a
/// `showReservePoints` változására épül újra, a GNSS-frissítésekre nem.
/// A koordináták a controllerben már átváltva vannak.
class _SourcePointsLayer extends StatelessWidget {
const _SourcePointsLayer();
@override
Widget build(BuildContext context) {
final c = VibroNavController.to;
return Obx(() {
final list = c.visibleGeoSourcePoints;
if (list.isEmpty) return const SizedBox.shrink();
return MarkerLayer(
markers: [
for (final g in list)
Marker(
point: g.latLng,
width: 74,
height: 26,
alignment: Alignment.centerLeft,
child: _SourcePointMarker(
isShot: g.point.isShotLocation,
label: _fmtPointNumber(g.point.pointNumber),
),
),
],
);
});
}
}
class _PulsingPositionMarker extends StatefulWidget {
final Color color;
const _PulsingPositionMarker({required this.color});
@override
State<_PulsingPositionMarker> createState() => _PulsingPositionMarkerState();
}
class _PulsingPositionMarkerState extends State<_PulsingPositionMarker>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl =
AnimationController(vsync: this, duration: const Duration(seconds: 2))
..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (context, _) {
final t = _ctrl.value;
return Stack(
alignment: Alignment.center,
children: [
Opacity(
opacity: (1 - t).clamp(0.0, 1.0),
child: Container(
width: 18 + t * 34,
height: 18 + t * 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color.withOpacity(0.35),
),
),
),
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2.5),
boxShadow: const [
BoxShadow(color: Colors.black38, blurRadius: 4),
],
),
),
],
);
},
);
}
}
class _CompassWidget extends StatelessWidget {
const _CompassWidget();
@override
Widget build(BuildContext context) {
final controller = VibroNavController.to;
return Obx(() {
final heading = controller.travelHeading.value ?? 0;
final rotation = controller.trackUpEnabled.value ? -heading : heading;
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 3)],
),
child: Transform.rotate(
angle: rotation * math.pi / 180,
child:
const Icon(Icons.navigation, color: Colors.redAccent, size: 22),
),
);
});
}
}
class _VehicleBadge extends StatelessWidget {
const _VehicleBadge();
@override
Widget build(BuildContext context) {
return Obx(() {
final vehicle = VehicleIdentityService.to.selectedVehicle.value;
return Material(
elevation: 2,
borderRadius: BorderRadius.circular(8),
color: Colors.white,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _pickVehicle(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.local_shipping_outlined, size: 18),
const SizedBox(width: 6),
Flexible(
child: Text(vehicle ?? 'Jármű kiválasztása',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
color: vehicle == null ? Colors.orange : null)),
),
],
),
),
),
);
});
}
void _pickVehicle(BuildContext context) {
Get.bottomSheet(
Material(
color: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final v in VehicleIdentityService.availableVehicles)
ListTile(
title: Text(v),
onTap: () {
VehicleIdentityService.to.setVehicle(v);
Get.back();
},
),
],
),
),
);
}
}
class _GnssStatusBadge extends StatelessWidget {
const _GnssStatusBadge();
@override
Widget build(BuildContext context) {
if (!Get.isRegistered<GnssService>()) return const SizedBox.shrink();
return Obx(() {
final gnss = GnssService.to;
final state = gnss.connectionState.value;
final quality = gnss.gpsQuality.value;
Color color;
IconData icon;
String label;
if (state == GnssConnectionState.connected && quality > 0) {
color = Colors.green;
icon = Icons.gps_fixed;
label = quality >= 4 ? 'RTK fix' : (quality == 2 ? 'RTK float' : 'GPS');
} else if (state == GnssConnectionState.connected) {
color = Colors.orange;
icon = Icons.gps_not_fixed;
label = 'Nincs fix';
} else if (state == GnssConnectionState.connecting) {
color = Colors.orange;
icon = Icons.gps_not_fixed;
label = 'Csatlakozás…';
} else {
color = Colors.red;
icon = Icons.gps_off;
label = 'Nincs GNSS';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color, width: 1.5),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 3)],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 4),
Text(label,
style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w700, color: color)),
],
),
);
});
}
}
class _PositionHud extends StatelessWidget {
const _PositionHud();
@override
Widget build(BuildContext context) {
final controller = VibroNavController.to;
return Obx(() {
final gnss = GnssService.to;
if (!controller.hasPosition.value) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.72),
borderRadius: BorderRadius.circular(16),
boxShadow: const [BoxShadow(color: Colors.black45, blurRadius: 10)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
controller.speedKmh.value.toStringAsFixed(0),
style: const TextStyle(
fontSize: 44,
fontWeight: FontWeight.bold,
color: Colors.white,
height: 1,
fontFeatures: [FontFeature.tabularFigures()]),
),
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 6),
child: Text('km/h',
style: TextStyle(color: Colors.white70, fontSize: 13)),
),
const Spacer(),
_UploadPulseDot(
active: controller.externalUploadPulse.value,
label: 'Külső'),
const SizedBox(width: 10),
_UploadPulseDot(
active: controller.internalUploadPulse.value,
label: 'Belső'),
],
),
const SizedBox(height: 6),
Text(
'${gnss.latitude.value.toStringAsFixed(6)}, '
'${gnss.longitude.value.toStringAsFixed(6)}',
style: const TextStyle(
fontSize: 13,
color: Colors.white70,
fontFeatures: [FontFeature.tabularFigures()]),
),
const SizedBox(height: 6),
Row(
children: [
_hudChip(Icons.satellite_alt, '${gnss.satelliteCount.value}'),
const SizedBox(width: 12),
_hudChip(Icons.height,
'${gnss.altitude.value.toStringAsFixed(1)} m'),
const SizedBox(width: 12),
_hudChip(Icons.adjust,
'±${math.max(gnss.latitudeError.value, gnss.longitudeError.value).toStringAsFixed(2)} m'),
],
),
],
),
);
});
}
Widget _hudChip(IconData icon, String value) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: Colors.white54),
const SizedBox(width: 3),
Text(value,
style: const TextStyle(fontSize: 12, color: Colors.white70)),
],
);
}
class _UploadPulseDot extends StatelessWidget {
final bool active;
final String label;
const _UploadPulseDot({required this.active, required this.label});
@override
Widget build(BuildContext context) {
return Column(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active ? Colors.greenAccent : Colors.white24,
boxShadow: active
? [
BoxShadow(
color: Colors.greenAccent.withOpacity(0.7),
blurRadius: 6)
]
: null,
),
),
const SizedBox(height: 2),
Text(label, style: const TextStyle(fontSize: 9, color: Colors.white54)),
],
);
}
}
String _fmtPointNumber(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
class _SourcePointMarker extends StatelessWidget {
final bool isShot;
final String label;
const _SourcePointMarker({required this.isShot, required this.label});
@override
Widget build(BuildContext context) {
// Jól látható, telített színek napfényben — a jelgerjesztés helye
// élénk piros-narancs, a tartalék pont cián; mindkettő fekete
// körvonallal, hogy világos térkép-háttéren se vesszen el.
final dotColor = isShot ? const Color(0xFFFF1744) : const Color(0xFF00E5FF);
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: isShot ? 11 : 8,
height: isShot ? 11 : 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: dotColor,
border: Border.all(color: Colors.black87, width: 1),
),
),
const SizedBox(width: 3),
_OutlinedLabel(text: label, color: dotColor),
],
);
}
}
/// Felirat HÁTTÉR NÉLKÜL, de fehér körvonallal a betűk körül — így
/// bármilyen térkép-háttéren olvasható marad, nincs szükség kitöltött
/// buborékra.
class _OutlinedLabel extends StatelessWidget {
final String text;
final Color color;
const _OutlinedLabel({required this.text, required this.color});
@override
Widget build(BuildContext context) {
return Stack(
children: [
Text(
text,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = Colors.white,
),
),
Text(
text,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: color,
),
),
],
);
}
}
/// A mai napra érvényes útvonalak (kerülők). Saját Obx-ben él, ezért
/// GNSS-frissítésre nem rajzolódik újra.
class _RoutesLayer extends StatelessWidget {
const _RoutesLayer();
@override
Widget build(BuildContext context) {
final c = VibroNavController.to;
return Obx(() {
final list = c.visibleRoutes;
if (list.isEmpty) return const SizedBox.shrink();
return PolylineLayer(polylines: [
for (final r in list)
for (final line in r.lines)
Polyline(
points: line,
strokeWidth: 5,
color:
VibroNavRoute.colorFor(r.routeKind, r.color).withOpacity(0.9),
borderStrokeWidth: 2,
borderColor: Colors.white,
),
]);
});
}
}