550 lines
19 KiB
Dart
550 lines
19 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
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/source_point.dart';
|
|
import 'package:terepi_seged/models/vechicle_position_log.dart';
|
|
import 'package:terepi_seged/services/app_database.dart';
|
|
import 'package:terepi_seged/services/coord_converter_service.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/layer_import_service.dart';
|
|
import 'package:terepi_seged/services/project_service.dart';
|
|
import 'package:terepi_seged/services/sps_import_service.dart';
|
|
import 'package:terepi_seged/services/vechicle_identity_service.dart';
|
|
import 'package:terepi_seged/widgets/map/animated_map_follow.dart';
|
|
import 'package:terepi_seged/widgets/map/imported_layer_overlay.dart';
|
|
|
|
import '../controllers/vibro_nav_controller.dart';
|
|
|
|
/// Egyszerű navigációs oldal a vibrátor-járműhöz. ÖNÁLLÓ oldal, nem
|
|
/// MapSurveyMode — saját MapController kell a forgó (track-up)
|
|
/// térképhez, hogy ez semmilyen más módot ne érintsen.
|
|
class VibroNavView extends GetView<VibroNavController> {
|
|
const VibroNavView({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Navigáció'),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.bluetooth_connected),
|
|
tooltip: "Gyors újracsatlakozás a GNSS vevőhöz",
|
|
onPressed: _quickReconnectGnss,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.layers_outlined),
|
|
tooltip: 'Réteg importja (GeoJSON/KML/KMZ)',
|
|
onPressed: _importLayer,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.upload_file),
|
|
tooltip: 'Forráspontok importja (SPS S-fájl)',
|
|
onPressed: _importSourcePoints,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.local_shipping_outlined),
|
|
tooltip: 'Jármű kiválasztása',
|
|
onPressed: _pickVehicle,
|
|
),
|
|
],
|
|
),
|
|
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: [
|
|
_buildMap(),
|
|
Builder(builder: (context) {
|
|
final target = controller.hasPosition.value &&
|
|
Get.isRegistered<GnssService>()
|
|
? LatLng(GnssService.to.latitude.value,
|
|
GnssService.to.longitude.value)
|
|
: null;
|
|
return AnimatedMapFollow(
|
|
mapController: controller.mapController,
|
|
target: target,
|
|
heading: controller.travelHeading.value,
|
|
);
|
|
}),
|
|
Positioned(
|
|
left: 8,
|
|
right: 8,
|
|
top: 8,
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: _VehicleBadge()),
|
|
const SizedBox(width: 8),
|
|
const _GnssStatusBadge(),
|
|
],
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 8,
|
|
right: 8,
|
|
bottom: 8,
|
|
child: _NavPanel(controller: controller),
|
|
),
|
|
],
|
|
);
|
|
}));
|
|
}
|
|
|
|
Widget _buildMap() {
|
|
return Obx(() {
|
|
final points = controller.sourcePoints;
|
|
final nearest = controller.nearestPoint.value;
|
|
|
|
final gnssPos =
|
|
Get.isRegistered<GnssService>() && GnssService.to.latitude.value != 0
|
|
? LatLng(
|
|
GnssService.to.latitude.value, GnssService.to.longitude.value)
|
|
: null;
|
|
final center = gnssPos ?? const LatLng(47.5, 19.05);
|
|
|
|
return FlutterMap(
|
|
mapController: controller.mapController,
|
|
options: MapOptions(
|
|
initialCenter: center,
|
|
initialZoom: 15,
|
|
),
|
|
children: [
|
|
// TileLayer(
|
|
// urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
// userAgentPackageName: 'hu.app_dev.terepi_seged',
|
|
// ),
|
|
TileLayer(
|
|
urlTemplate: 'http://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}',
|
|
subdomains: const ['mt0', 'mt1', 'mt2', 'mt3'],
|
|
maxNativeZoom: 18,
|
|
),
|
|
|
|
// Tervezett útvonal + minden más importált háttérréteg — a
|
|
// MEGLÉVŐ rétegimport-mechanizmus, nincs hozzá új kód.
|
|
const ImportedLayerOverlay(),
|
|
MarkerLayer(markers: [
|
|
for (final sp in points)
|
|
Marker(
|
|
point: LatLng(sp.planLat, sp.planLon),
|
|
width: 40,
|
|
height: 40,
|
|
child: _SourcePointMarker(
|
|
point: sp,
|
|
isNearest: sp.uuid == nearest?.uuid,
|
|
isVerified: controller.isVerified(sp, controller.logs),
|
|
),
|
|
),
|
|
if (controller.hasPosition.value)
|
|
Marker(
|
|
point: LatLng(GnssService.to.latitude.value,
|
|
GnssService.to.longitude.value),
|
|
width: 34,
|
|
height: 34,
|
|
child: const _VehicleMarker(),
|
|
),
|
|
]),
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
// ── Jármű-választás ────────────────────────────────────────────────
|
|
|
|
Future<void> _pickVehicle() async {
|
|
final chosen = await Get.dialog<String>(AlertDialog(
|
|
title: const Text('Melyik járműben van ez a tablet?'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final v in VehicleIdentityService.availableVehicles)
|
|
RadioListTile<String>(
|
|
title: Text(v),
|
|
value: v,
|
|
groupValue: VehicleIdentityService.to.selectedVehicle.value,
|
|
onChanged: (val) => Get.back(result: val),
|
|
),
|
|
],
|
|
),
|
|
));
|
|
if (chosen != null) {
|
|
await VehicleIdentityService.to.setVehicle(chosen);
|
|
}
|
|
}
|
|
|
|
Future<void> _quickReconnectGnss() async {
|
|
final device = GnssDeviceService.to.selectedDevice.value;
|
|
if (device == null) {
|
|
Get.snackbar(
|
|
'Nincs korábbi GNSS-eszköz',
|
|
'Előbb válassz ki egyet a Kitűzés/Mérés beállításaiban.',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
return;
|
|
}
|
|
Get.snackbar(
|
|
'Újracsatlakozás',
|
|
'„${device.name}" (${device.typeLabel}) — folyamatban…',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
duration: const Duration(seconds: 2),
|
|
);
|
|
await GnssService.to.reconnect();
|
|
}
|
|
|
|
Future<void> _importLayer() async {
|
|
try {
|
|
final layer = await LayerImportService.to.importFile();
|
|
if (layer != null) {
|
|
Get.snackbar('Réteg importálva', layer.name,
|
|
snackPosition: SnackPosition.BOTTOM);
|
|
}
|
|
} catch (e) {
|
|
Get.snackbar('Import hiba', e.toString(),
|
|
snackPosition: SnackPosition.BOTTOM);
|
|
}
|
|
}
|
|
|
|
// ── SPS S-fájl (forráspont) import — kompakt, egyetlen dialógus ────
|
|
|
|
Future<void> _importSourcePoints() async {
|
|
final result = await FilePicker.platform.pickFiles(type: FileType.any);
|
|
final path = result?.files.single.path;
|
|
if (path == null) return;
|
|
|
|
final projectId = ProjectService.to.activeProjectId;
|
|
if (projectId == null) return;
|
|
|
|
try {
|
|
final content = await File(path).readAsString();
|
|
final points = SpsParser.parseSourceFile(content);
|
|
if (points.isEmpty) {
|
|
Get.snackbar(
|
|
'Import',
|
|
'Nem sikerült forráspontot beolvasni ebből a fájlból — '
|
|
'ellenőrizd, hogy valódi SPS S-fájl-e.',
|
|
snackPosition: SnackPosition.BOTTOM);
|
|
return;
|
|
}
|
|
|
|
final withPos = points.where((p) => p.eovY != null && p.eovX != null);
|
|
final ok = await Get.dialog<bool>(AlertDialog(
|
|
title: const Text('Forráspontok importja'),
|
|
content: Text('${points.length} pont az S-fájlban, '
|
|
'${withPos.length} érvényes koordinátával.\n\n'
|
|
'Első néhány: ${points.take(3).map((p) => '${p.lineId}·${p.station}').join(', ')}…'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Get.back(result: false),
|
|
child: const Text('Mégse')),
|
|
FilledButton(
|
|
onPressed: () => Get.back(result: true),
|
|
child: const Text('Import')),
|
|
],
|
|
));
|
|
if (ok != true) return;
|
|
|
|
final conv = CoordConverterService.to;
|
|
final batch = DateTime.now().toIso8601String();
|
|
final saved = <SourcePoint>[];
|
|
for (final p in withPos) {
|
|
final w = conv.eovToWgsPoint(p.eovY!, p.eovX!);
|
|
saved.add(SourcePoint(
|
|
projectId: projectId,
|
|
lineId: p.lineId,
|
|
station: p.station,
|
|
planEovY: p.eovY!,
|
|
planEovX: p.eovX!,
|
|
planLat: w.y,
|
|
planLon: w.x,
|
|
importBatch: batch,
|
|
));
|
|
}
|
|
final inserted = await AppDatabase.instance.insertSourcePoints(saved);
|
|
await controller.load();
|
|
await controller.refreshLogs();
|
|
Get.snackbar('Import kész', '$inserted forráspont importálva',
|
|
snackPosition: SnackPosition.BOTTOM);
|
|
} catch (e) {
|
|
Get.snackbar('Import hiba', e.toString(),
|
|
snackPosition: SnackPosition.BOTTOM);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// HUD panel: sebesség, táv, legközelebbi pont, rögzítés vezérlés
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
class _NavPanel extends StatelessWidget {
|
|
final VibroNavController controller;
|
|
const _NavPanel({required this.controller});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Obx(() {
|
|
final nearest = controller.nearestPoint.value;
|
|
return Card(
|
|
elevation: 6,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_Stat(
|
|
label: 'Sebesség',
|
|
value:
|
|
'${controller.speedKmh.value.toStringAsFixed(0)} km/h'),
|
|
_Stat(
|
|
label: 'Megtett táv',
|
|
value: _fmtDist(controller.sessionDistanceM.value)),
|
|
if (nearest != null)
|
|
_Stat(
|
|
label: 'Legközelebbi VP',
|
|
value: nearest.displayId,
|
|
highlight: true),
|
|
if (nearest != null)
|
|
_Stat(
|
|
label: 'Táv odáig',
|
|
value: _fmtDist(controller.nearestDistance.value)),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: controller.isLogging.value
|
|
? OutlinedButton.icon(
|
|
icon: const Icon(Icons.stop_circle_outlined),
|
|
label: Text('Rögzítés leállítása '
|
|
'(${controller.logIntervalSec.value} mp)'),
|
|
onPressed: () {
|
|
controller.stopLogging();
|
|
},
|
|
)
|
|
: FilledButton.icon(
|
|
icon: const Icon(Icons.fiber_manual_record,
|
|
color: Colors.red),
|
|
label: Text('Rögzítés indítása '
|
|
'(${controller.logIntervalSec.value} mp-enként)'),
|
|
onPressed: () {
|
|
controller.startLogging();
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: const Icon(Icons.timer_outlined),
|
|
tooltip: 'Rögzítési időköz',
|
|
onPressed: controller.isLogging.value
|
|
? null
|
|
: () => _pickInterval(controller),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<void> _pickInterval(VibroNavController c) async {
|
|
final opts = [15, 30, 60, 120, 300];
|
|
final chosen = await Get.dialog<int>(AlertDialog(
|
|
title: const Text('Rögzítési időköz'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (final s in opts)
|
|
RadioListTile<int>(
|
|
title: Text(s < 60 ? '$s másodperc' : '${s ~/ 60} perc'),
|
|
value: s,
|
|
groupValue: c.logIntervalSec.value,
|
|
onChanged: (v) => Get.back(result: v),
|
|
),
|
|
],
|
|
),
|
|
));
|
|
if (chosen != null) c.logIntervalSec.value = chosen;
|
|
}
|
|
|
|
static String _fmtDist(double m) => m < 1000
|
|
? '${m.toStringAsFixed(0)} m'
|
|
: '${(m / 1000).toStringAsFixed(2)} km';
|
|
}
|
|
|
|
class _Stat extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final bool highlight;
|
|
const _Stat(
|
|
{required this.label, required this.value, this.highlight = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(value,
|
|
style: TextStyle(
|
|
fontSize: highlight ? 16 : 18,
|
|
fontWeight: FontWeight.w700,
|
|
color:
|
|
highlight ? Theme.of(context).colorScheme.primary : null)),
|
|
Text(label,
|
|
style: TextStyle(fontSize: 10, color: Colors.grey.shade600)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
class _VehicleBadge extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Obx(() {
|
|
final v = VehicleIdentityService.to.selectedVehicle.value;
|
|
return Align(
|
|
alignment: Alignment.topLeft,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: v == null
|
|
? Colors.orange.withOpacity(0.9)
|
|
: Colors.black.withOpacity(0.7),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
v == null ? 'Nincs jármű kiválasztva!' : 'Jármű: $v',
|
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
class _SourcePointMarker extends StatelessWidget {
|
|
final SourcePoint point;
|
|
final bool isNearest;
|
|
final bool isVerified;
|
|
const _SourcePointMarker(
|
|
{required this.point, required this.isNearest, required this.isVerified});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = isVerified ? Colors.green : Colors.grey.shade600;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
isVerified ? Icons.check_circle : Icons.radio_button_unchecked,
|
|
color: isNearest ? Colors.red : color,
|
|
size: isNearest ? 26 : 18,
|
|
),
|
|
Text(point.displayId,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: isNearest ? FontWeight.w700 : FontWeight.w400,
|
|
color: isNearest ? Colors.red : Colors.black87)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _VehicleMarker extends StatelessWidget {
|
|
const _VehicleMarker();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Track-up módban a szimbólum mindig "felfelé" mutat — a világ
|
|
// forog körülötte, nem a szimbólum a világ körül.
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 3),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.blue.withOpacity(0.5), blurRadius: 8)
|
|
],
|
|
),
|
|
child: const Icon(Icons.navigation, color: Colors.white, size: 18),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.15),
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: color, width: 1),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 14, color: color),
|
|
const SizedBox(width: 4),
|
|
Text(label,
|
|
style: TextStyle(
|
|
fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
|
],
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|