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 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 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> watchForToday(String projectId) => _watch(projectId, todayOnly: true); /// Az admin oldalnak: MINDEN útvonal, dátumtól és állapottól függetlenül. Stream> watchAll(String projectId) => _watch(projectId, todayOnly: false); Stream> _watch(String projectId, {required bool todayOnly}) { late StreamController> controller; RealtimeChannel? channel; Timer? debounce; var disposed = false; var fetching = false; var again = false; Future 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>( 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> _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 = []; 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 insertRoutes(List> rows) async { if (rows.isEmpty) return; await _client.from(_table).insert(rows); } Future updateRoute(String id, Map patch) async { await _client.from(_table).update(patch).eq('id', id); } Future 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 geometry, Map props})>[]; void addGeometry(dynamic g, dynamic props) { if (g is Map) { items.add(( geometry: Map.from(g), props: props is Map ? Map.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 = []; 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 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; } }