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:
@@ -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