Files
MobilApp/lib/services/vibro_nav_route_service.dart
T

254 lines
8.4 KiB
Dart
Raw Normal View History

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;
}
}