Files

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)),
],
),
);
});
}
}