import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../pages/ntrip_settings/presentation/views/ntrip_settings_sheet.dart'; import '../services/gnss/gnss_connection.dart'; import '../services/gnss/gnss_device_service.dart'; import '../services/gnss/gnss_service.dart'; import '../services/ntrip_service.dart'; /// NTRIP állapotkártya — bottom sheet. /// /// A mérnök két kérdésére válaszol: /// 1. „Jön a korrekció?" → kapcsolat, KORREKCIÓ KORA (a legfontosabb /// egészség-metrika: élő TCP mellett is kiderül, ha az adat elakadt), /// adatforgalom, GGA-küldés. /// 2. „Lett belőle RTK fix?" → fix típusa + becsült hiba a GNSS oldalról. /// /// Megnyitás: az appbar NTRIP-chipjére koppintva (NtripStatusSheet.show()). class NtripStatusSheet extends StatefulWidget { const NtripStatusSheet({super.key}); static Future show() { return Get.bottomSheet( const NtripStatusSheet(), isScrollControlled: true, ); } @override State createState() => _NtripStatusSheetState(); } class _NtripStatusSheetState extends State { Timer? _ticker; bool _busy = false; @override void initState() { super.initState(); // Másodpercenkénti frissítés a "korrekció kora" / időtartam számláláshoz. _ticker = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); } @override void dispose() { _ticker?.cancel(); super.dispose(); } Future _toggleConnection() async { final ntrip = NtripService.to; setState(() => _busy = true); try { if (ntrip.isConnected.value) { await ntrip.disconnect(); } else { await ntrip.connect(); Get.snackbar('NTRIP', 'Kapcsolódva: ${ntrip.mountpoint.value}', snackPosition: SnackPosition.BOTTOM, backgroundColor: const Color(0xFF2E7D32), colorText: const Color(0xFFFFFFFF)); } } on NtripException catch (e) { Get.snackbar('NTRIP', e.message, snackPosition: SnackPosition.BOTTOM, backgroundColor: const Color(0xFFB71C1C), colorText: const Color(0xFFFFFFFF), duration: const Duration(seconds: 4)); } catch (e) { Get.snackbar('NTRIP', e.toString(), snackPosition: SnackPosition.BOTTOM, backgroundColor: const Color(0xFFB71C1C), colorText: const Color(0xFFFFFFFF)); } finally { if (mounted) setState(() => _busy = false); } } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return SafeArea( top: false, child: Material( color: colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), clipBehavior: Clip.antiAlias, child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), child: Obx(() { final ntrip = NtripService.to; final connected = ntrip.isConnected.value; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( width: 42, height: 4, margin: const EdgeInsets.only(bottom: 14), decoration: BoxDecoration( color: colorScheme.outlineVariant, borderRadius: BorderRadius.circular(999), ), ), ), // ── Fejléc: állapot + caster ──────────────────────── Row( children: [ Icon(Icons.cell_tower, color: connected ? Colors.green : Colors.grey), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( connected ? 'NTRIP kapcsolódva' : 'NTRIP nincs kapcsolat', style: Theme.of(context).textTheme.titleMedium, ), Text( ntrip.hasCompleteSettings ? '${ntrip.host.value} · ${ntrip.mountpoint.value}' : 'Nincs beállítva', style: TextStyle( fontSize: 12, color: Colors.grey.shade600), ), ], ), ), if (connected && ntrip.connectedSince.value != null) Text( _formatDuration(DateTime.now() .difference(ntrip.connectedSince.value!)), style: TextStyle( fontSize: 12, color: Colors.grey.shade600), ), ], ), if (ntrip.lastError.value.isNotEmpty && !connected) ...[ const SizedBox(height: 8), _ErrorBanner(text: ntrip.lastError.value), ], const SizedBox(height: 14), // ── Korrekció kora — a fő egészség-jelző ─────────── _CorrectionAgeTile( connected: connected, lastRtcmAt: ntrip.lastRtcmAt.value, ), const SizedBox(height: 10), // ── Metrikák rácsban ─────────────────────────────── Row( children: [ Expanded( child: _MetricTile( label: 'Fogadott adat', value: _formatBytes(ntrip.receivedBytes.value), sub: '${ntrip.packetCount.value} csomag', ), ), const SizedBox(width: 8), Expanded( child: _MetricTile( label: 'GGA küldve', value: '${ntrip.ggaSentCount.value} db', sub: ntrip.ggaLastSentTime.value.isEmpty ? 'még nem' : 'utolsó: ${ntrip.ggaLastSentTime.value} UTC', warn: connected && ntrip.ggaSentCount.value == 0, ), ), ], ), const SizedBox(height: 10), // ── Eredmény: a GNSS oldal ───────────────────────── _FixResultTile(), const SizedBox(height: 16), // ── Gombok ───────────────────────────────────────── Row( children: [ Expanded( child: OutlinedButton.icon( icon: const Icon(Icons.settings, size: 18), label: const Text('Beállítások'), onPressed: () { Get.back(); NtripSettingsSheet.show(); }, ), ), const SizedBox(width: 12), Expanded( child: FilledButton.icon( icon: _busy ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon(connected ? Icons.link_off : Icons.link, size: 18), label: Text(connected ? 'Bontás' : 'Kapcsolódás'), style: connected ? FilledButton.styleFrom( backgroundColor: colorScheme.errorContainer, foregroundColor: colorScheme.onErrorContainer, ) : null, onPressed: _busy ? null : _toggleConnection, ), ), ], ), ], ); }), ), ), ); } } // ═════════════════════════════════════════════════════════════════════ // Rész-widgetek // ═════════════════════════════════════════════════════════════════════ /// A korrekció kora, színkódolva: <2 s zöld, 2–10 s sárga, >10 s piros. /// Ez mutatja meg azt az alattomos állapotot is, amikor a TCP-kapcsolat /// él, de adat már nem jön (mobilnet-lyuk). class _CorrectionAgeTile extends StatelessWidget { final bool connected; final DateTime? lastRtcmAt; const _CorrectionAgeTile({required this.connected, this.lastRtcmAt}); @override Widget build(BuildContext context) { String text; String label = 'Korrekció kora'; Color color; IconData icon; if (!connected) { text = '—'; label = 'Korrekció'; color = Colors.grey; icon = Icons.satellite_alt_outlined; } else if (lastRtcmAt == null) { text = 'várakozás az első csomagra…'; color = Colors.orange; icon = Icons.hourglass_top; } else { final age = DateTime.now().difference(lastRtcmAt!); final s = age.inSeconds; text = s < 1 ? '<1 s' : '$s s'; if (s < 2) { color = Colors.green; icon = Icons.check_circle; } else if (s <= 10) { color = Colors.orange; icon = Icons.warning_amber_rounded; } else { color = Colors.red; icon = Icons.error; label = 'Korrekció ELAVULT'; } } return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: color.withOpacity(0.10), borderRadius: BorderRadius.circular(12), border: Border.all(color: color.withOpacity(0.4)), ), child: Row( children: [ Icon(icon, color: color), const SizedBox(width: 10), Expanded( child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), ), Text( text, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 16, color: color, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), ); } } /// Fix-eredmény a GNSS oldalról: a kör bezárul — látszik, hogy a /// korrekció ténylegesen "megfogant-e" (RTK fixed a cél). class _FixResultTile extends StatelessWidget { @override Widget build(BuildContext context) { return Obx(() { if (!Get.isRegistered()) return const SizedBox.shrink(); final gnss = GnssService.to; final receiverConnected = gnss.connectionState.value == GnssConnectionState.connected; final type = gnss.activeConnectionType.value; if (!receiverConnected) { return const _MetricTile( label: 'GNSS vevő', value: 'Nincs csatlakoztatva', sub: 'A korrekcióhoz külső vevő kell', warn: true, ); } if (type == GnssConnectionType.phoneGps) { return const _MetricTile( label: 'GNSS forrás', value: 'Telefon GPS', sub: 'RTK korrekció fogadására nem képes', warn: true, ); } final quality = gnss.gpsQuality.value; final (label, color, icon) = switch (quality) { 4 => ('RTK FIXED', Colors.green, Icons.verified), 5 => ('RTK FLOAT', Colors.orange, Icons.adjust), 2 => ('DGPS', Colors.blue, Icons.gps_fixed), 1 => ('GPS (önálló)', Colors.grey, Icons.gps_not_fixed), 0 => ('Nincs fix', Colors.red, Icons.gps_off), _ => ('Fix: $quality', Colors.grey, Icons.gps_fixed), }; final hErr = math.sqrt(math.pow(gnss.latitudeError.value, 2) + math.pow(gnss.longitudeError.value, 2)); final errText = hErr > 0 ? '±${hErr < 1 ? '${(hErr * 100).toStringAsFixed(0)} cm' : '${hErr.toStringAsFixed(2)} m'} vízszintes' : 'HDOP: ${gnss.hdop.value.toStringAsFixed(1)}'; return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: color.withOpacity(0.10), borderRadius: BorderRadius.circular(12), border: Border.all(color: color.withOpacity(0.4)), ), child: Row( children: [ Icon(icon, color: color), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(fontWeight: FontWeight.w700, color: color)), Text( '$errText · ${gnss.gsaUsedSatelliteCount.value} műhold', style: TextStyle(fontSize: 12, color: Colors.grey.shade700), ), ], ), ), ], ), ); }); } } class _MetricTile extends StatelessWidget { final String label; final String value; final String sub; final bool warn; const _MetricTile({ required this.label, required this.value, required this.sub, this.warn = false, }); @override Widget build(BuildContext context) { final color = warn ? Colors.orange : Colors.grey; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: warn ? Border.all(color: Colors.orange.withOpacity(0.5)) : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), const SizedBox(height: 2), Text(value, style: TextStyle( fontWeight: FontWeight.w700, color: warn ? Colors.orange.shade800 : null, fontFeatures: const [FontFeature.tabularFigures()], )), Text(sub, style: TextStyle(fontSize: 11, color: color.withOpacity(0.9))), ], ), ); } } class _ErrorBanner extends StatelessWidget { final String text; const _ErrorBanner({required this.text}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Colors.red.withOpacity(0.08), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ const Icon(Icons.error_outline, size: 16, color: Colors.red), const SizedBox(width: 8), Expanded( child: Text(text, style: const TextStyle(fontSize: 12, color: Colors.red)), ), ], ), ); } } String _formatDuration(Duration d) { final h = d.inHours; final m = d.inMinutes % 60; final s = d.inSeconds % 60; if (h > 0) return '$h ó $m p'; if (m > 0) return '$m:${s.toString().padLeft(2, '0')}'; return '$s s'; } String _formatBytes(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; return '${(bytes / 1024 / 1024).toStringAsFixed(2)} MB'; }