Navigációs képernyőn útvonal megjelenítése supabase táblából és navigációs képernyővel történő indítás beállítások alapján
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
This commit is contained in:
@@ -8,6 +8,7 @@ import 'package:get/get.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart';
|
||||
import 'package:terepi_seged/pages/update_required/presentation/views/update_required_view.dart';
|
||||
import 'package:terepi_seged/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart';
|
||||
import 'package:terepi_seged/routes/app_pages.dart';
|
||||
import 'package:terepi_seged/services/app_database.dart';
|
||||
import 'package:terepi_seged/services/app_logger.dart';
|
||||
@@ -36,6 +37,7 @@ import 'package:terepi_seged/services/track_sync_service.dart';
|
||||
import 'package:terepi_seged/services/ts_sync_service.dart';
|
||||
import 'package:terepi_seged/services/vechicle_identity_service.dart';
|
||||
import 'package:terepi_seged/services/version_gate_service.dart';
|
||||
import 'package:terepi_seged/services/vibro_nav_route_service.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -121,6 +123,7 @@ Future<void> main() async {
|
||||
Get.put(ParcelGeometryService());
|
||||
Get.put(MeasuredDataService());
|
||||
Get.put(MeasureSourcePointService());
|
||||
Get.put(VibroNavRouteService());
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:terepi_seged/services/coord_converter_service.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
/// Egy navigációs útvonal (kerülő, megközelítés…) a
|
||||
/// `terepi_seged_vibro_nav_routes` táblából.
|
||||
///
|
||||
/// A `geometry` az EREDETI GeoJSON (wgs84 vagy eov), a `lines` pedig a
|
||||
/// BETÖLTÉSKOR egyszer, WGS84-re átváltott, kirajzolásra kész vonalak —
|
||||
/// a térkép így nem számol újra GNSS-frissítéskor.
|
||||
class VibroNavRoute {
|
||||
final String id;
|
||||
final String projectId;
|
||||
final String name;
|
||||
final Map<String, dynamic> geometry;
|
||||
final String crs; // 'auto' | 'wgs84' | 'eov'
|
||||
final DateTime? validDate;
|
||||
final String? vehicle;
|
||||
final String routeKind; // 'detour' | 'access' | 'restricted'
|
||||
final String? color;
|
||||
final bool isActive;
|
||||
final List<List<LatLng>> lines;
|
||||
|
||||
const VibroNavRoute({
|
||||
required this.id,
|
||||
required this.projectId,
|
||||
required this.name,
|
||||
required this.geometry,
|
||||
required this.crs,
|
||||
required this.validDate,
|
||||
required this.vehicle,
|
||||
required this.routeKind,
|
||||
required this.color,
|
||||
required this.isActive,
|
||||
required this.lines,
|
||||
});
|
||||
|
||||
factory VibroNavRoute.fromMap(Map<String, dynamic> m) {
|
||||
final geometry = Map<String, dynamic>.from(m['geometry'] as Map);
|
||||
final crs = (m['crs'] as String?) ?? 'auto';
|
||||
final dateStr = m['valid_date'] as String?;
|
||||
return VibroNavRoute(
|
||||
id: m['id'] as String,
|
||||
projectId: m['project_id'] as String,
|
||||
name: (m['name'] as String?) ?? '',
|
||||
geometry: geometry,
|
||||
crs: crs,
|
||||
validDate: dateStr == null ? null : DateTime.parse(dateStr),
|
||||
vehicle: (m['vehicle'] as String?)?.trim().isEmpty ?? true
|
||||
? null
|
||||
: (m['vehicle'] as String).trim(),
|
||||
routeKind: (m['route_kind'] as String?) ?? 'detour',
|
||||
color: m['color'] as String?,
|
||||
isActive: (m['is_active'] as bool?) ?? true,
|
||||
lines: toWgsLines(geometry, crs),
|
||||
);
|
||||
}
|
||||
|
||||
int get pointCount => lines.fold(0, (s, l) => s + l.length);
|
||||
|
||||
String get dateLabel => validDate == null
|
||||
? 'minden nap'
|
||||
: '${validDate!.year}-${validDate!.month.toString().padLeft(2, '0')}-'
|
||||
'${validDate!.day.toString().padLeft(2, '0')}';
|
||||
|
||||
String get vehicleLabel => vehicle ?? 'minden jármű';
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Fajták / színek
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
static const kinds = <String, String>{
|
||||
'detour': 'Kerülő',
|
||||
'access': 'Megközelítés',
|
||||
'restricted': 'Tiltott / veszélyes',
|
||||
};
|
||||
|
||||
static Color colorFor(String kind, String? hex) {
|
||||
final custom = _parseHex(hex);
|
||||
if (custom != null) return custom;
|
||||
switch (kind) {
|
||||
case 'access':
|
||||
return const Color(0xFF2979FF);
|
||||
case 'restricted':
|
||||
return const Color(0xFFD50000);
|
||||
case 'detour':
|
||||
default:
|
||||
return const Color(0xFFFFAB00);
|
||||
}
|
||||
}
|
||||
|
||||
static Color? _parseHex(String? hex) {
|
||||
if (hex == null) return null;
|
||||
var h = hex.trim().replaceFirst('#', '');
|
||||
if (h.length == 6) h = 'FF$h';
|
||||
if (h.length != 8) return null;
|
||||
final v = int.tryParse(h, radix: 16);
|
||||
return v == null ? null : Color(v);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Geometria → WGS84 vonalak
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Megállapítja a koordinátarendszert az ELSŐ koordináta alapján
|
||||
/// (egy geometrián belül egységes vetületet feltételezünk).
|
||||
/// WGS84: |x| ≤ 180 és |y| ≤ 90. EOV: több tízezres–százezres értékek.
|
||||
static String detectCrs(Map<String, dynamic> geometry) {
|
||||
final first = _firstCoordinate(geometry);
|
||||
if (first == null) return 'wgs84';
|
||||
return (first[0].abs() <= 180 && first[1].abs() <= 90) ? 'wgs84' : 'eov';
|
||||
}
|
||||
|
||||
static List<double>? _firstCoordinate(Map<String, dynamic> geometry) {
|
||||
final lines = _rawLines(geometry);
|
||||
for (final l in lines) {
|
||||
if (l.isNotEmpty) return l.first;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A GeoJSON geometria nyers vonalai ([x, y] párok listájának listája).
|
||||
static List<List<List<double>>> _rawLines(Map<String, dynamic> geometry) {
|
||||
final type = geometry['type'];
|
||||
final coords = geometry['coordinates'];
|
||||
if (coords is! List) return const [];
|
||||
|
||||
List<List<double>> parseLine(dynamic raw) {
|
||||
final out = <List<double>>[];
|
||||
if (raw is! List) return out;
|
||||
for (final c in raw) {
|
||||
if (c is List && c.length >= 2 && c[0] is num && c[1] is num) {
|
||||
out.add([(c[0] as num).toDouble(), (c[1] as num).toDouble()]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (type == 'LineString') return [parseLine(coords)];
|
||||
if (type == 'MultiLineString') return coords.map(parseLine).toList();
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// WGS84 [LatLng] vonalak. A nem véges vagy érvénytelen pontokat
|
||||
/// eldobja (a LatLng NaN összeomlasztja a térképet), a 2 pontnál
|
||||
/// rövidebb vonalakat kihagyja.
|
||||
static List<List<LatLng>> toWgsLines(
|
||||
Map<String, dynamic> geometry, String crs) {
|
||||
final raw = _rawLines(geometry);
|
||||
if (raw.isEmpty) return const [];
|
||||
|
||||
final effective = crs == 'auto' ? detectCrs(geometry) : crs;
|
||||
final out = <List<LatLng>>[];
|
||||
|
||||
for (final line in raw) {
|
||||
final pts = <LatLng>[];
|
||||
for (final c in line) {
|
||||
final ll = effective == 'eov' ? _eovToLatLng(c[0], c[1]) : _wgs(c);
|
||||
if (ll != null) pts.add(ll);
|
||||
}
|
||||
if (pts.length >= 2) out.add(pts);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static LatLng? _wgs(List<double> c) {
|
||||
var lon = c[0];
|
||||
var lat = c[1];
|
||||
// Gyakori hiba: felcserélt sorrend [szélesség, hosszúság]. Magyarország
|
||||
// területén a szélesség 45–49, a hosszúság 16–23, ezért egyértelmű.
|
||||
if (lon > 44 && lon < 50 && lat > 15 && lat < 24) {
|
||||
final t = lon;
|
||||
lon = lat;
|
||||
lat = t;
|
||||
}
|
||||
if (!lon.isFinite || !lat.isFinite) return null;
|
||||
if (lon.abs() > 180 || lat.abs() > 90) return null;
|
||||
return LatLng(lat, lon);
|
||||
}
|
||||
|
||||
static LatLng? _eovToLatLng(double a, double b) {
|
||||
if (!Get.isRegistered<CoordConverterService>()) return null;
|
||||
// Szabvány: [EOV Y (kelet ~400k–950k), EOV X (észak ~45k–350k)].
|
||||
// Ha felcserélve érkezett, javítjuk.
|
||||
var y = a;
|
||||
var x = b;
|
||||
if (y < 400000 && x > 400000) {
|
||||
final t = y;
|
||||
y = x;
|
||||
x = t;
|
||||
}
|
||||
try {
|
||||
final w = CoordConverterService.to.eovToWgsPoint(y, x);
|
||||
if (w.x.isFinite && w.y.isFinite) return LatLng(w.y, w.x);
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:terepi_seged/routes/app_pages.dart';
|
||||
import 'package:terepi_seged/services/nav_autostart_service.dart';
|
||||
|
||||
class StartPageController extends GetxController {
|
||||
late PermissionStatus bluetoothStatus;
|
||||
@@ -97,8 +98,17 @@ class StartPageController extends GetxController {
|
||||
}
|
||||
|
||||
void _departStartPage() async {
|
||||
final autoStartNav = NavAutoStartService.shouldStartNavigation();
|
||||
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
final startNav = await autoStartNav;
|
||||
log('🚀 NavAutoStart döntés: startNav=$startNav');
|
||||
//Get.offAllNamed(Routes.HOME);
|
||||
Get.offAllNamed(Routes.SHELL);
|
||||
|
||||
if (startNav) {
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
Get.toNamed(Routes.VIBRONAV);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:get/get.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:terepi_seged/models/measure_source_point.dart';
|
||||
import 'package:terepi_seged/models/vibro_nav_route.dart';
|
||||
import 'package:terepi_seged/services/app_logger.dart';
|
||||
import 'package:terepi_seged/services/coord_converter_service.dart';
|
||||
import 'package:terepi_seged/services/gnss/gnss_connection.dart';
|
||||
@@ -16,6 +17,7 @@ import 'package:terepi_seged/services/gnss/phone_gps_connection.dart';
|
||||
import 'package:terepi_seged/services/measure_source_point_service.dart';
|
||||
import 'package:terepi_seged/services/project_service.dart';
|
||||
import 'package:terepi_seged/services/vechicle_identity_service.dart';
|
||||
import 'package:terepi_seged/services/vibro_nav_route_service.dart';
|
||||
|
||||
/// Forráspont + egyszer kiszámolt WGS84 pozíció. A térkép ebből rajzol,
|
||||
/// így GNSS-frissítéskor nincs újraszámolás.
|
||||
@@ -66,6 +68,9 @@ class VibroNavController extends GetxController {
|
||||
final Map<String, LatLng?> _geoCache = {};
|
||||
final isLoadingSourcePoints = false.obs;
|
||||
|
||||
final routes = <VibroNavRoute>[].obs;
|
||||
StreamSubscription<List<VibroNavRoute>>? _routesSub;
|
||||
|
||||
/// Ha false, a tartalék (plan_state = 0) pontok nem jelennek meg a
|
||||
/// térképen — csak a jelgerjesztés helyei (plan_state = 1) látszanak.
|
||||
final showReservePoints = false.obs;
|
||||
@@ -93,7 +98,11 @@ class VibroNavController extends GetxController {
|
||||
super.onInit();
|
||||
|
||||
watchSourcePoints();
|
||||
ever(ProjectService.to.activeProject, (_) => watchSourcePoints());
|
||||
watchRoutes();
|
||||
ever(ProjectService.to.activeProject, (_) {
|
||||
watchSourcePoints();
|
||||
watchRoutes();
|
||||
});
|
||||
|
||||
if (Get.isRegistered<GnssService>()) {
|
||||
_gnssUpdateSub =
|
||||
@@ -130,6 +139,7 @@ class VibroNavController extends GetxController {
|
||||
void onClose() {
|
||||
_gnssUpdateSub?.cancel();
|
||||
_sourcePointsSub?.cancel();
|
||||
_routesSub?.cancel();
|
||||
_externalUploadTimer?.cancel();
|
||||
_internalUploadTimer?.cancel();
|
||||
_internalPositionSub?.cancel();
|
||||
@@ -357,4 +367,26 @@ class VibroNavController extends GetxController {
|
||||
List<GeoSourcePoint> get visibleGeoSourcePoints => showReservePoints.value
|
||||
? geoSourcePoints
|
||||
: geoSourcePoints.where((g) => g.point.isShotLocation).toList();
|
||||
|
||||
void watchRoutes() {
|
||||
_routesSub?.cancel();
|
||||
final projectId = ProjectService.to.activeProject.value?.uuid;
|
||||
if (projectId == null) {
|
||||
routes.clear();
|
||||
return;
|
||||
}
|
||||
_routesSub = VibroNavRouteService.to.watchForToday(projectId).listen(
|
||||
(list) => routes.value = list,
|
||||
onError: (e) =>
|
||||
AppLogger.e('VibroNavController', 'Útvonal-stream hiba: $e'),
|
||||
);
|
||||
}
|
||||
|
||||
/// A térkép ezt rajzolja: a mai napra érvényes útvonalak közül azok,
|
||||
/// amelyek minden járműre szólnak, vagy erre a járműre. A járműváltásra
|
||||
/// azonnal reagál.
|
||||
List<VibroNavRoute> get visibleRoutes {
|
||||
final v = VehicleIdentityService.to.selectedVehicle.value;
|
||||
return routes.where((r) => r.vehicle == null || r.vehicle == v).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:terepi_seged/models/vibro_nav_route.dart';
|
||||
import 'package:terepi_seged/services/app_logger.dart';
|
||||
import 'package:terepi_seged/services/project_service.dart';
|
||||
import 'package:terepi_seged/services/vibro_nav_route_service.dart';
|
||||
|
||||
/// Az útvonalak kezelő (admin) oldalának controllere: lista, GeoJSON-import,
|
||||
/// szerkesztés (név, dátum, jármű, fajta, aktív), törlés.
|
||||
class VibroNavRoutesController extends GetxController {
|
||||
static VibroNavRoutesController get to => Get.find();
|
||||
|
||||
final routes = <VibroNavRoute>[].obs;
|
||||
final isLoading = true.obs;
|
||||
final isBusy = false.obs;
|
||||
|
||||
// Közös űrlap-állapot (import ÉS szerkesztés).
|
||||
final nameCtrl = TextEditingController();
|
||||
final formDate = Rxn<DateTime>(); // null = minden nap
|
||||
final formVehicle = Rxn<String>(); // null = minden jármű
|
||||
final formKind = 'detour'.obs;
|
||||
final formActive = true.obs;
|
||||
|
||||
VibroNavRoute? _editing;
|
||||
List<RouteDraft> _drafts = [];
|
||||
|
||||
bool get isEditing => _editing != null;
|
||||
VibroNavRoute? get editingRoute => _editing;
|
||||
int get draftCount => _drafts.length;
|
||||
|
||||
StreamSubscription<List<VibroNavRoute>>? _sub;
|
||||
Worker? _projectWorker;
|
||||
|
||||
String? get projectId => ProjectService.to.activeProject.value?.uuid;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_subscribe();
|
||||
_projectWorker = ever(ProjectService.to.activeProject, (_) => _subscribe());
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_sub?.cancel();
|
||||
_projectWorker?.dispose();
|
||||
nameCtrl.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
_sub?.cancel();
|
||||
final pid = projectId;
|
||||
if (pid == null) {
|
||||
routes.clear();
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
_sub = VibroNavRouteService.to.watchAll(pid).listen(
|
||||
(list) {
|
||||
routes.value = list;
|
||||
isLoading.value = false;
|
||||
},
|
||||
onError: (e) {
|
||||
isLoading.value = false;
|
||||
AppLogger.e('VibroNavRoutesController', 'Útvonal-lista hiba: $e');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Űrlap-segédek
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
|
||||
|
||||
static bool sameDay(DateTime? a, DateTime? b) =>
|
||||
a != null && b != null && _dateOnly(a) == _dateOnly(b);
|
||||
|
||||
void setDateNone() => formDate.value = null;
|
||||
|
||||
void setDateOffset(int days) =>
|
||||
formDate.value = _dateOnly(DateTime.now().add(Duration(days: days)));
|
||||
|
||||
Future<void> pickDate(BuildContext context) async {
|
||||
final now = DateTime.now();
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: formDate.value ?? now,
|
||||
firstDate: DateTime(now.year - 1),
|
||||
lastDate: DateTime(now.year + 2),
|
||||
);
|
||||
if (picked != null) formDate.value = _dateOnly(picked);
|
||||
}
|
||||
|
||||
String? _dateStr(DateTime? d) => d == null
|
||||
? null
|
||||
: '${d.year}-${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}';
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Szerkesztés
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
void beginEdit(VibroNavRoute r) {
|
||||
_editing = r;
|
||||
_drafts = [];
|
||||
nameCtrl.text = r.name;
|
||||
formDate.value = r.validDate;
|
||||
formVehicle.value = r.vehicle;
|
||||
formKind.value = r.routeKind;
|
||||
formActive.value = r.isActive;
|
||||
}
|
||||
|
||||
Future<void> toggleActive(VibroNavRoute r, bool value) async {
|
||||
try {
|
||||
await VibroNavRouteService.to.updateRoute(r.id, {'is_active': value});
|
||||
} catch (e) {
|
||||
AppLogger.e('VibroNavRoutesController', 'Állapotváltás hiba: $e');
|
||||
Get.snackbar('Hiba', 'Az állapot módosítása nem sikerült: $e',
|
||||
snackPosition: SnackPosition.BOTTOM);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteRoute(VibroNavRoute r) async {
|
||||
try {
|
||||
await VibroNavRouteService.to.deleteRoute(r.id);
|
||||
} catch (e) {
|
||||
AppLogger.e('VibroNavRoutesController', 'Törlés hiba: $e');
|
||||
Get.snackbar('Hiba', 'A törlés nem sikerült: $e',
|
||||
snackPosition: SnackPosition.BOTTOM);
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// GeoJSON-import
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Fájlválasztás + értelmezés. Visszatérés: a talált útvonalak száma,
|
||||
/// vagy null, ha megszakadt / hibás a fájl (a hibát már kiírta).
|
||||
Future<int?> pickAndParse() async {
|
||||
if (projectId == null) {
|
||||
Get.snackbar('Nincs aktív projekt', 'Előbb válassz ki egy projektet.',
|
||||
snackPosition: SnackPosition.BOTTOM);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Az Android a .geojson kiterjesztést nem ismeri (Drive-ról sem
|
||||
// választható FileType.custom-mal), ezért FileType.any + kézi ellenőrzés.
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.any);
|
||||
if (result == null || result.files.isEmpty) return null;
|
||||
|
||||
final file = result.files.single;
|
||||
final lower = file.name.toLowerCase();
|
||||
if (!lower.endsWith('.geojson') && !lower.endsWith('.json')) {
|
||||
Get.snackbar('Nem GeoJSON', 'Válassz .geojson vagy .json fájlt.',
|
||||
snackPosition: SnackPosition.BOTTOM);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String content;
|
||||
if (file.bytes != null) {
|
||||
content = utf8.decode(file.bytes!);
|
||||
} else if (file.path != null) {
|
||||
content = await File(file.path!).readAsString();
|
||||
} else {
|
||||
throw const FormatException('A fájl nem olvasható.');
|
||||
}
|
||||
|
||||
final base = file.name
|
||||
.replaceAll(RegExp(r'\.(geo)?json$', caseSensitive: false), '');
|
||||
final parsed = VibroNavRouteService.parseGeoJson(content, base);
|
||||
|
||||
_editing = null;
|
||||
_drafts = parsed.drafts;
|
||||
nameCtrl.clear();
|
||||
setDateOffset(0); // alapból a MAI napra érvényes
|
||||
formVehicle.value = null;
|
||||
formKind.value = 'detour';
|
||||
formActive.value = true;
|
||||
|
||||
if (parsed.skipped > 0) {
|
||||
Get.snackbar('Figyelem',
|
||||
'${parsed.skipped} nem vonal geometriájú elem kimaradt.',
|
||||
snackPosition: SnackPosition.BOTTOM);
|
||||
}
|
||||
return parsed.drafts.length;
|
||||
} on FormatException catch (e) {
|
||||
Get.snackbar('Import hiba', e.message,
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 6));
|
||||
return null;
|
||||
} catch (e) {
|
||||
AppLogger.e('VibroNavRoutesController', 'Import hiba: $e');
|
||||
Get.snackbar('Import hiba', '$e', snackPosition: SnackPosition.BOTTOM);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Mentés (szerkesztés VAGY import)
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
Future<bool> saveForm() async {
|
||||
final pid = projectId;
|
||||
if (pid == null) return false;
|
||||
|
||||
if (_editing != null && nameCtrl.text.trim().isEmpty) {
|
||||
Get.snackbar('Hiányzó név', 'Adj nevet az útvonalnak.',
|
||||
snackPosition: SnackPosition.BOTTOM);
|
||||
return false;
|
||||
}
|
||||
|
||||
isBusy.value = true;
|
||||
try {
|
||||
final common = {
|
||||
'valid_date': _dateStr(formDate.value),
|
||||
'vehicle': formVehicle.value,
|
||||
'route_kind': formKind.value,
|
||||
'is_active': formActive.value,
|
||||
};
|
||||
|
||||
if (_editing != null) {
|
||||
await VibroNavRouteService.to.updateRoute(
|
||||
_editing!.id, {'name': nameCtrl.text.trim(), ...common});
|
||||
} else {
|
||||
await VibroNavRouteService.to.insertRoutes([
|
||||
for (final d in _drafts)
|
||||
{
|
||||
'project_id': pid,
|
||||
'name': d.name,
|
||||
'geometry': d.geometry,
|
||||
'crs': d.crs,
|
||||
...common,
|
||||
}
|
||||
]);
|
||||
}
|
||||
_editing = null;
|
||||
_drafts = [];
|
||||
return true;
|
||||
} catch (e) {
|
||||
AppLogger.e('VibroNavRoutesController', 'Mentési hiba: $e');
|
||||
Get.snackbar(
|
||||
'Mentési hiba',
|
||||
'Az útvonal nem menthető. Cloud-projekt kell hozzá, és be kell '
|
||||
'lenned jelentkezve.\n$e',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 6),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:terepi_seged/models/vibro_nav_route.dart';
|
||||
import 'package:terepi_seged/pages/vibro_nav/presentation/controllers/vibro_nav_routes_controller.dart';
|
||||
import 'package:terepi_seged/services/vechicle_identity_service.dart';
|
||||
|
||||
/// Útvonalak kezelése: lista, GeoJSON-import, szerkesztés, törlés.
|
||||
/// Megnyitás: `VibroNavRoutesView.open()` (a binding-et is felveszi).
|
||||
class VibroNavRoutesView extends GetView<VibroNavRoutesController> {
|
||||
const VibroNavRoutesView({super.key});
|
||||
|
||||
static void open() {
|
||||
Get.to(
|
||||
() => const VibroNavRoutesView(),
|
||||
binding: BindingsBuilder(() {
|
||||
Get.put(VibroNavRoutesController());
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Útvonalak')),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.upload_file),
|
||||
label: const Text('GeoJSON import'),
|
||||
onPressed: () async {
|
||||
final n = await controller.pickAndParse();
|
||||
if (n != null) _showForm();
|
||||
},
|
||||
),
|
||||
body: Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final list = controller.routes;
|
||||
if (list.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Ehhez a projekthez még nincs útvonal.\n'
|
||||
'A "GeoJSON import" gombbal tölthetsz fel vonalakat, '
|
||||
'vagy a PC-szoftver is feltöltheti őket.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (_, i) => _RouteTile(route: list[i]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static void _showForm() {
|
||||
Get.bottomSheet(
|
||||
const _RouteFormSheet(),
|
||||
isScrollControlled: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteTile extends StatelessWidget {
|
||||
final VibroNavRoute route;
|
||||
const _RouteTile({required this.route});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = VibroNavRoutesController.to;
|
||||
final color = VibroNavRoute.colorFor(route.routeKind, route.color);
|
||||
final today = DateTime.now();
|
||||
final isToday = VibroNavRoutesController.sameDay(route.validDate, today);
|
||||
final isPast = route.validDate != null &&
|
||||
!isToday &&
|
||||
route.validDate!.isBefore(DateTime(today.year, today.month, today.day));
|
||||
|
||||
return ListTile(
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(route.isActive && !isPast ? 1 : 0.35),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.alt_route, color: Colors.white, size: 20),
|
||||
),
|
||||
title: Text(route.name),
|
||||
isThreeLine: true,
|
||||
subtitle: Text.rich(TextSpan(children: [
|
||||
TextSpan(
|
||||
text: isToday ? 'MA' : route.dateLabel,
|
||||
style: TextStyle(
|
||||
fontWeight: isToday ? FontWeight.w800 : FontWeight.w500,
|
||||
color: isToday ? Colors.green.shade700 : null,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: ' · ${route.vehicleLabel} · '
|
||||
'${VibroNavRoute.kinds[route.routeKind] ?? route.routeKind}\n'
|
||||
'${route.lines.length} vonal, ${route.pointCount} pont'
|
||||
'${isPast ? ' · lejárt' : ''}'),
|
||||
])),
|
||||
trailing: Switch(
|
||||
value: route.isActive,
|
||||
onChanged: (v) => c.toggleActive(route, v),
|
||||
),
|
||||
onTap: () {
|
||||
c.beginEdit(route);
|
||||
VibroNavRoutesView._showForm();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Közös űrlap: import (több útvonal egyszerre) ÉS egy útvonal szerkesztése.
|
||||
/// A lap alapja Material (nem Container(color:)), hogy a ListTile/chip
|
||||
/// effektek ne takarodjanak el.
|
||||
class _RouteFormSheet extends StatelessWidget {
|
||||
const _RouteFormSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = VibroNavRoutesController.to;
|
||||
final editing = c.isEditing;
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16, 16, 16, 16 + MediaQuery.of(context).viewInsets.bottom),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
editing
|
||||
? 'Útvonal szerkesztése'
|
||||
: '${c.draftCount} útvonal importálása',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (editing) ...[
|
||||
TextField(
|
||||
controller: c.nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Név',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
const _SectionTitle('Mely napon látszik?'),
|
||||
Obx(() {
|
||||
final d = c.formDate.value;
|
||||
final isNone = d == null;
|
||||
final isToday =
|
||||
VibroNavRoutesController.sameDay(d, DateTime.now());
|
||||
final isTomorrow = VibroNavRoutesController.sameDay(
|
||||
d, DateTime.now().add(const Duration(days: 1)));
|
||||
final custom = !isNone && !isToday && !isTomorrow;
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Minden nap'),
|
||||
selected: isNone,
|
||||
onSelected: (_) => c.setDateNone(),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('Ma'),
|
||||
selected: isToday,
|
||||
onSelected: (_) => c.setDateOffset(0),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('Holnap'),
|
||||
selected: isTomorrow,
|
||||
onSelected: (_) => c.setDateOffset(1),
|
||||
),
|
||||
ChoiceChip(
|
||||
avatar: const Icon(Icons.event, size: 16),
|
||||
label: Text(custom
|
||||
? '${d!.year}-${d.month.toString().padLeft(2, '0')}-'
|
||||
'${d.day.toString().padLeft(2, '0')}'
|
||||
: 'Dátum…'),
|
||||
selected: custom,
|
||||
onSelected: (_) => c.pickDate(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 12),
|
||||
const _SectionTitle('Melyik járműnek?'),
|
||||
Obx(() => Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Minden jármű'),
|
||||
selected: c.formVehicle.value == null,
|
||||
onSelected: (_) => c.formVehicle.value = null,
|
||||
),
|
||||
for (final v
|
||||
in VehicleIdentityService.availableVehicles)
|
||||
ChoiceChip(
|
||||
label: Text(v),
|
||||
selected: c.formVehicle.value == v,
|
||||
onSelected: (_) => c.formVehicle.value = v,
|
||||
),
|
||||
],
|
||||
)),
|
||||
const SizedBox(height: 12),
|
||||
const _SectionTitle('Fajta (a térképi szín)'),
|
||||
Obx(() => Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final e in VibroNavRoute.kinds.entries)
|
||||
ChoiceChip(
|
||||
avatar: CircleAvatar(
|
||||
backgroundColor:
|
||||
VibroNavRoute.colorFor(e.key, null),
|
||||
radius: 6),
|
||||
label: Text(e.value),
|
||||
selected: c.formKind.value == e.key,
|
||||
onSelected: (_) => c.formKind.value = e.key,
|
||||
),
|
||||
],
|
||||
)),
|
||||
const SizedBox(height: 4),
|
||||
Obx(() => SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Aktív'),
|
||||
value: c.formActive.value,
|
||||
onChanged: (v) => c.formActive.value = v,
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
Obx(() => Row(
|
||||
children: [
|
||||
if (editing)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
color: Colors.red),
|
||||
label: const Text('Törlés',
|
||||
style: TextStyle(color: Colors.red)),
|
||||
onPressed: c.isBusy.value
|
||||
? null
|
||||
: () => _confirmDelete(context, c),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: c.isBusy.value ? null : () => Get.back(),
|
||||
child: const Text('Mégse'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: c.isBusy.value
|
||||
? null
|
||||
: () async {
|
||||
final ok = await c.saveForm();
|
||||
if (ok) Get.back();
|
||||
},
|
||||
child: c.isBusy.value
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(editing ? 'Mentés' : 'Importálás'),
|
||||
),
|
||||
],
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context, VibroNavRoutesController c) async {
|
||||
final route = c.editingRoute;
|
||||
if (route == null) return;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Útvonal törlése'),
|
||||
content: Text('Biztosan törlöd: „${route.name}"?\n'
|
||||
'Minden járműről eltűnik.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Mégse')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Törlés')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await c.deleteRoute(route);
|
||||
Get.back();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String text;
|
||||
const _SectionTitle(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(text,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,9 @@ 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';
|
||||
@@ -27,6 +29,11 @@ class VibroNavView extends GetView<VibroNavController> {
|
||||
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
|
||||
@@ -181,6 +188,7 @@ class _NavMap extends StatelessWidget {
|
||||
subdomains: const ['mt0', 'mt1', 'mt2', 'mt3'],
|
||||
maxNativeZoom: 18),
|
||||
const _SourcePointsLayer(),
|
||||
const _RoutesLayer(),
|
||||
Obx(() => controller.trail.length > 1
|
||||
? PolylineLayer(polylines: [
|
||||
Polyline(
|
||||
@@ -648,3 +656,30 @@ class _OutlinedLabel extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:terepi_seged/models/project.dart';
|
||||
import 'package:terepi_seged/services/app_database.dart';
|
||||
import 'package:terepi_seged/services/app_logger.dart';
|
||||
|
||||
/// Induláskor eldönti, hogy a navigációs képernyőt is meg kell-e nyitni.
|
||||
///
|
||||
/// Mind a négy feltételnek teljesülnie kell:
|
||||
/// 1. A felhasználó be van jelentkezve (a munkamenet a készüléken tárolt,
|
||||
/// ez offline is eldönthető).
|
||||
/// 2. Az utoljára aktív projekt FELHŐS (nem csak helyi), aktív, és már
|
||||
/// szinkronizált (sync_status == 'synced').
|
||||
/// 3. A szerver elérhető — ezt maga a lekérdezés bizonyítja (offline hibára
|
||||
/// fut).
|
||||
/// 4. A `terepi_seged_app_config` táblában megtalálható a
|
||||
/// `nav_autostart:<ANDROID_ID>` kulcs. Az érték tetszőleges (pl. 'igen'),
|
||||
/// csak a kulcs megléte számít. A járművet (V1, V2…) NEM állítja be, az
|
||||
/// marad a helyi érték.
|
||||
///
|
||||
/// OFFLINE-FIRST: bármilyen hiba, időtúllépés vagy hiányzó adat esetén a
|
||||
/// válasz `false`, az app a megszokott módon indul. Sosem dob kivételt.
|
||||
class NavAutoStartService {
|
||||
NavAutoStartService._();
|
||||
|
||||
static const keyPrefix = 'nav_autostart:';
|
||||
static const _channel = MethodChannel('hu.app_dev.terepi_seged/deviceInfo');
|
||||
|
||||
static bool _no(String reason) {
|
||||
// ignore: avoid_print
|
||||
print('🚀 NavAutoStart: NEM — $reason');
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> shouldStartNavigation() async {
|
||||
try {
|
||||
final androidId = await _androidId();
|
||||
if (androidId == null || androidId.isEmpty) {
|
||||
return _no('nincs ANDROID_ID');
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print('🚀 NavAutoStart: androidId=$androidId');
|
||||
|
||||
if (Supabase.instance.client.auth.currentUser == null) {
|
||||
return _no('nincs bejelentkezett felhasználó');
|
||||
}
|
||||
|
||||
if (!await _activeProjectIsOnline()) return false;
|
||||
|
||||
final row = await Supabase.instance.client
|
||||
.from('terepi_seged_app_config')
|
||||
.select('key')
|
||||
.eq('key', '$keyPrefix$androidId')
|
||||
.maybeSingle()
|
||||
.timeout(const Duration(seconds: 3));
|
||||
if (row == null) {
|
||||
return _no('nincs sor a kulccsal: $keyPrefix$androidId');
|
||||
}
|
||||
|
||||
AppLogger.event('nav_autostart');
|
||||
// ignore: avoid_print
|
||||
print('🚀 NavAutoStart: IGEN');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return _no('hiba / időtúllépés: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Az utoljára aktív projektet a ProjectService is a SharedPreferences-ből
|
||||
/// állítja vissza, ezért itt közvetlenül azt vizsgáljuk — így nem függ a
|
||||
/// ProjectService aszinkron indulásának időzítésétől.
|
||||
static Future<bool> _activeProjectIsOnline() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final id = prefs.getInt('active_project_id');
|
||||
if (id == null) return _no('nincs mentett aktív projekt');
|
||||
|
||||
final project = await AppDatabase.instance.getProject(id);
|
||||
if (project == null) return _no('a projekt nincs a helyi adatbázisban');
|
||||
if (project.isLocalOnly) return _no('az aktív projekt csak helyi');
|
||||
if (project.status != ProjectStatus.active) {
|
||||
return _no('a projekt nem aktív (${project.status})');
|
||||
}
|
||||
|
||||
final sync = await AppDatabase.instance.getProjectSyncStatus(id);
|
||||
if (sync != 'synced') return _no('sync_status=$sync (nem synced)');
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Settings.Secure.ANDROID_ID — ugyanaz a csatorna, mint amit a
|
||||
/// DeviceIdentityService használ.
|
||||
static Future<String?> _androidId() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<String>('getAndroidId');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:terepi_seged/models/vibro_nav_route.dart';
|
||||
import 'package:terepi_seged/services/app_logger.dart';
|
||||
|
||||
/// Egy GeoJSON-ból kiolvasott, még NEM mentett útvonal.
|
||||
class RouteDraft {
|
||||
final String name;
|
||||
final Map<String, dynamic> geometry;
|
||||
final String crs; // 'wgs84' | 'eov' (az import eldönti)
|
||||
const RouteDraft(
|
||||
{required this.name, required this.geometry, required this.crs});
|
||||
}
|
||||
|
||||
class RouteParseResult {
|
||||
final List<RouteDraft> drafts;
|
||||
final int skipped; // nem vonal geometriájú elemek (pont, terület…)
|
||||
const RouteParseResult(this.drafts, this.skipped);
|
||||
}
|
||||
|
||||
/// Navigációs útvonalak (kerülők) — élő lekérdezés + szerkesztés.
|
||||
///
|
||||
/// Az olvasás ugyanazt a bevált mintát követi, mint a forráspontoknál:
|
||||
/// szerveroldalon szűrt lekérdezés + Realtime csatorna, ami CSAK jelzés
|
||||
/// (debounce-olt újratöltés). A `.stream()`-et SZÁNDÉKOSAN kerüljük.
|
||||
class VibroNavRouteService extends GetxService {
|
||||
static VibroNavRouteService get to => Get.find();
|
||||
|
||||
static const _table = 'terepi_seged_vibro_nav_routes';
|
||||
|
||||
SupabaseClient get _client => Supabase.instance.client;
|
||||
|
||||
/// A térképnek: csak az aktív, MAI napra érvényes vagy dátum nélküli
|
||||
/// útvonalak. (A járműre szűrést a controller végzi, az a kis lista
|
||||
/// miatt kliensoldalon is olcsó, és a járműváltásra azonnal reagál.)
|
||||
Stream<List<VibroNavRoute>> watchForToday(String projectId) =>
|
||||
_watch(projectId, todayOnly: true);
|
||||
|
||||
/// Az admin oldalnak: MINDEN útvonal, dátumtól és állapottól függetlenül.
|
||||
Stream<List<VibroNavRoute>> watchAll(String projectId) =>
|
||||
_watch(projectId, todayOnly: false);
|
||||
|
||||
Stream<List<VibroNavRoute>> _watch(String projectId,
|
||||
{required bool todayOnly}) {
|
||||
late StreamController<List<VibroNavRoute>> controller;
|
||||
RealtimeChannel? channel;
|
||||
Timer? debounce;
|
||||
var disposed = false;
|
||||
var fetching = false;
|
||||
var again = false;
|
||||
|
||||
Future<void> refetch() async {
|
||||
if (disposed) return;
|
||||
if (fetching) {
|
||||
again = true;
|
||||
return;
|
||||
}
|
||||
fetching = true;
|
||||
try {
|
||||
do {
|
||||
again = false;
|
||||
final list = await _fetch(projectId, todayOnly);
|
||||
if (!disposed) controller.add(list);
|
||||
} while (again && !disposed);
|
||||
} catch (e, s) {
|
||||
if (!disposed) controller.addError(e, s);
|
||||
} finally {
|
||||
fetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
controller = StreamController<List<VibroNavRoute>>(
|
||||
onListen: () {
|
||||
refetch();
|
||||
channel = _client
|
||||
.channel('vibro_routes_${projectId}_${todayOnly ? 'day' : 'all'}')
|
||||
.onPostgresChanges(
|
||||
event: PostgresChangeEvent.all,
|
||||
schema: 'public',
|
||||
table: _table,
|
||||
filter: PostgresChangeFilter(
|
||||
type: PostgresChangeFilterType.eq,
|
||||
column: 'project_id',
|
||||
value: projectId,
|
||||
),
|
||||
callback: (_) {
|
||||
debounce?.cancel();
|
||||
debounce = Timer(const Duration(milliseconds: 500), refetch);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
},
|
||||
onCancel: () async {
|
||||
disposed = true;
|
||||
debounce?.cancel();
|
||||
if (channel != null) await _client.removeChannel(channel!);
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
String _todayStr() {
|
||||
final n = DateTime.now();
|
||||
return '${n.year}-${n.month.toString().padLeft(2, '0')}-'
|
||||
'${n.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Future<List<VibroNavRoute>> _fetch(String projectId, bool todayOnly) async {
|
||||
final base = _client.from(_table).select().eq('project_id', projectId);
|
||||
final filtered = todayOnly
|
||||
? base
|
||||
.eq('is_active', true)
|
||||
.or('valid_date.eq.${_todayStr()},valid_date.is.null')
|
||||
: base;
|
||||
|
||||
final rows = await filtered
|
||||
.order('valid_date', ascending: false, nullsFirst: true)
|
||||
.order('name')
|
||||
.limit(500);
|
||||
|
||||
final out = <VibroNavRoute>[];
|
||||
for (final r in rows) {
|
||||
try {
|
||||
out.add(VibroNavRoute.fromMap(r));
|
||||
} catch (e) {
|
||||
// Egyetlen hibás sor ne akassza meg az egész listát.
|
||||
AppLogger.e('VibroNavRouteService', 'Hibás útvonal-sor kihagyva: $e');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Írás
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
Future<void> insertRoutes(List<Map<String, dynamic>> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
await _client.from(_table).insert(rows);
|
||||
}
|
||||
|
||||
Future<void> updateRoute(String id, Map<String, dynamic> patch) async {
|
||||
await _client.from(_table).update(patch).eq('id', id);
|
||||
}
|
||||
|
||||
Future<void> deleteRoute(String id) async {
|
||||
await _client.from(_table).delete().eq('id', id);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// GeoJSON-import
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
static const _nameKeys = [
|
||||
'name',
|
||||
'nev',
|
||||
'név',
|
||||
'megnevezes',
|
||||
'megnevezés',
|
||||
'title',
|
||||
];
|
||||
|
||||
/// FeatureCollection / Feature / puszta geometria → vonal-útvonalak.
|
||||
/// Csak LineString és MultiLineString kerül be; a többit megszámolja.
|
||||
/// A koordinátarendszert (WGS84 / EOV) fájlonként a nagyságrend dönti el.
|
||||
static RouteParseResult parseGeoJson(String content, String fallbackName) {
|
||||
final dynamic json;
|
||||
try {
|
||||
json = jsonDecode(content);
|
||||
} catch (e) {
|
||||
throw FormatException('Érvénytelen GeoJSON (nem elemezhető JSON): $e');
|
||||
}
|
||||
|
||||
// Minden elemet (geometria + tulajdonságok) egységes listába szedünk.
|
||||
final items =
|
||||
<({Map<String, dynamic> geometry, Map<String, dynamic> props})>[];
|
||||
|
||||
void addGeometry(dynamic g, dynamic props) {
|
||||
if (g is Map) {
|
||||
items.add((
|
||||
geometry: Map<String, dynamic>.from(g),
|
||||
props: props is Map ? Map<String, dynamic>.from(props) : const {},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (json is Map && json['type'] == 'FeatureCollection') {
|
||||
final features = json['features'];
|
||||
if (features is List) {
|
||||
for (final f in features) {
|
||||
if (f is Map) addGeometry(f['geometry'], f['properties']);
|
||||
}
|
||||
}
|
||||
} else if (json is Map && json['type'] == 'Feature') {
|
||||
addGeometry(json['geometry'], json['properties']);
|
||||
} else if (json is Map && json['type'] != null) {
|
||||
addGeometry(json, null);
|
||||
}
|
||||
|
||||
if (items.isEmpty) {
|
||||
throw const FormatException('Nem található geometria a GeoJSON-ban.');
|
||||
}
|
||||
|
||||
final drafts = <RouteDraft>[];
|
||||
var skipped = 0;
|
||||
var counter = 0;
|
||||
|
||||
for (final it in items) {
|
||||
final type = it.geometry['type'];
|
||||
if (type != 'LineString' && type != 'MultiLineString') {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// A geometria legyen értelmezhető (legalább egy 2 pontos vonal).
|
||||
final crs = VibroNavRoute.detectCrs(it.geometry);
|
||||
final lines = VibroNavRoute.toWgsLines(it.geometry, crs);
|
||||
if (lines.isEmpty) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
counter++;
|
||||
drafts.add(RouteDraft(
|
||||
name: _nameFrom(it.props) ?? '$fallbackName $counter',
|
||||
geometry: {
|
||||
'type': type,
|
||||
'coordinates': it.geometry['coordinates'],
|
||||
},
|
||||
crs: crs,
|
||||
));
|
||||
}
|
||||
|
||||
if (drafts.isEmpty) {
|
||||
throw const FormatException(
|
||||
'A fájl nem tartalmaz érvényes vonal (LineString / MultiLineString) '
|
||||
'geometriát.');
|
||||
}
|
||||
return RouteParseResult(drafts, skipped);
|
||||
}
|
||||
|
||||
static String? _nameFrom(Map<String, dynamic> props) {
|
||||
final lower = {for (final e in props.entries) e.key.toLowerCase(): e.value};
|
||||
for (final k in _nameKeys) {
|
||||
final v = lower[k];
|
||||
if (v != null && v.toString().trim().isNotEmpty) {
|
||||
return v.toString().trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user