From 2bd39fbd4eb223f76b36209e85a01fae529b9175 Mon Sep 17 00:00:00 2001 From: "torok.istvan" Date: Sun, 5 Jul 2026 15:48:33 +0200 Subject: [PATCH] =?UTF-8?q?Ntrip=20be=C3=A1ll=C3=ADt=C3=A1sok=20sheet,=20n?= =?UTF-8?q?trip=20st=C3=A1tusz=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentations/views/settings_dialog.dart | 4 +- .../ntrip_settings_controller.dart | 38 ++ .../views/ntrip_settings_sheet.dart | 11 +- lib/services/ntrip_service.dart | 135 +++-- lib/widgets/appbar/shell_map_appbar.dart | 5 +- lib/widgets/ntrip_status_sheet.dart | 478 ++++++++++++++++++ 6 files changed, 629 insertions(+), 42 deletions(-) create mode 100644 lib/widgets/ntrip_status_sheet.dart diff --git a/lib/pages/map_survey/presentations/views/settings_dialog.dart b/lib/pages/map_survey/presentations/views/settings_dialog.dart index 775c9fc..acbb392 100644 --- a/lib/pages/map_survey/presentations/views/settings_dialog.dart +++ b/lib/pages/map_survey/presentations/views/settings_dialog.dart @@ -330,7 +330,7 @@ class SettingsDialog extends StatelessWidget { SizedBox( height: 40, child: TextField( - controller: NtripService.to.usernameController, + //controller: NtripService.to.usernameController, enableSuggestions: false, autocorrect: false, decoration: InputDecoration( @@ -355,7 +355,7 @@ class SettingsDialog extends StatelessWidget { //focusNode: controller.passwordFieldFocusNode, enableSuggestions: false, autocorrect: false, - controller: NtripService.to.passwordController, + //controller: NtripService.to.passwordController, decoration: InputDecoration( floatingLabelBehavior: FloatingLabelBehavior.never, isDense: true, diff --git a/lib/pages/ntrip_settings/presentation/controllers/ntrip_settings_controller.dart b/lib/pages/ntrip_settings/presentation/controllers/ntrip_settings_controller.dart index e886dde..8943365 100644 --- a/lib/pages/ntrip_settings/presentation/controllers/ntrip_settings_controller.dart +++ b/lib/pages/ntrip_settings/presentation/controllers/ntrip_settings_controller.dart @@ -116,4 +116,42 @@ class NtripSettingsController extends GetxController { passwordController.dispose(); super.onClose(); } + + /// „Kapcsolat tesztelése" — vevő nélkül is fut: mentés után csatlakozik, + /// validálja a caster válaszát, majd azonnal bont. + Future testConnection() async { + try { + isBusy.value = true; + if (!await _validateAndSave()) return; + + final message = await _service.testConnection(); + Get.snackbar( + 'NTRIP teszt sikeres', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: const Color(0xFF2E7D32), + colorText: const Color(0xFFFFFFFF), + duration: const Duration(seconds: 4), + ); + } on NtripException catch (e) { + Get.snackbar( + 'NTRIP teszt sikertelen', + e.message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: const Color(0xFFB71C1C), + colorText: const Color(0xFFFFFFFF), + duration: const Duration(seconds: 4), + ); + } catch (e) { + Get.snackbar( + 'NTRIP teszt sikertelen', + e.toString(), + snackPosition: SnackPosition.BOTTOM, + backgroundColor: const Color(0xFFB71C1C), + colorText: const Color(0xFFFFFFFF), + ); + } finally { + isBusy.value = false; + } + } } diff --git a/lib/pages/ntrip_settings/presentation/views/ntrip_settings_sheet.dart b/lib/pages/ntrip_settings/presentation/views/ntrip_settings_sheet.dart index a8c2ed9..aab6e66 100644 --- a/lib/pages/ntrip_settings/presentation/views/ntrip_settings_sheet.dart +++ b/lib/pages/ntrip_settings/presentation/views/ntrip_settings_sheet.dart @@ -157,7 +157,7 @@ class NtripSettingsSheet extends GetView { contentPadding: EdgeInsets.zero, title: const Text('Automatikus kapcsolódás'), subtitle: const Text( - 'Az app indításakor próbáljon NTRIP-re kapcsolódni.', + 'RTK képes vevő csatlakozásakor próbáljon automatikusan NTRIP-re kapcsolódni.', ), value: controller.autoConnect.value, onChanged: (value) { @@ -216,6 +216,15 @@ class NtripSettingsSheet extends GetView { child: const Text('Csak mentés'), ), ), + SizedBox( + width: double.infinity, + child: Obx(() => OutlinedButton.icon( + onPressed: controller.isBusy.value + ? null + : controller.testConnection, + icon: const Icon(Icons.network_check, size: 18), + label: const Text( + 'Kapcsolat tesztelése (vevő nélkül)')))) ], ), ), diff --git a/lib/services/ntrip_service.dart b/lib/services/ntrip_service.dart index 6520951..c6df4b0 100644 --- a/lib/services/ntrip_service.dart +++ b/lib/services/ntrip_service.dart @@ -9,6 +9,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.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'; class NtripException implements Exception { final String message; @@ -37,6 +40,9 @@ class NtripService extends GetxService { final ggaLastSentTime = ''.obs; final lastError = ''.obs; + final lastRtcmAt = Rxn(); + final connectedSince = Rxn(); + // ── Beállítások ─────────────────────────────────────────────────── final host = ''.obs; // gnssnet.hu IP final port = 2101.obs; @@ -48,12 +54,12 @@ class NtripService extends GetxService { bool get hasCompleteSettings => host.value.trim().isNotEmpty && mountpoint.value.trim().isNotEmpty; - // ── UI controllerek (beállítás dialóghoz) ──────────────────────── - final hostController = TextEditingController(); - final portController = TextEditingController(); - final mountpointController = TextEditingController(); - final usernameController = TextEditingController(); - final passwordController = TextEditingController(); + // // ── UI controllerek (beállítás dialóghoz) ──────────────────────── + // final hostController = TextEditingController(); + // final portController = TextEditingController(); + // final mountpointController = TextEditingController(); + // final usernameController = TextEditingController(); + // final passwordController = TextEditingController(); // ── Belső állapot ──────────────────────────────────────────────── static const _secure = FlutterSecureStorage(); @@ -76,23 +82,61 @@ class NtripService extends GetxService { Future onInit() async { super.onInit(); await _loadSettings(); - _syncControllersFromValues(); + // _syncControllersFromValues(); + _wireGnssListener(); + } - if (autoConnect.value && hasCompleteSettings) { - unawaited(connect().catchError((e) { - lastError.value = e.toString(); - })); + void _wireGnssListener() { + if (!Get.isRegistered()) { + Future.delayed(const Duration(seconds: 2), _wireGnssListener); + } + + ever(GnssService.to.connectionState, (state) { + if (state == GnssConnectionState.connected) { + final type = GnssService.to.activeConnectionType.value; + final isExternal = type == GnssConnectionType.btSerial || + type == GnssConnectionType.ble; + + if (isExternal && + autoConnect.value && + hasCompleteSettings && + !isConnected.value) { + unawaited(connect().catchError((e) { + lastError.value = e.tostring(); + })); + } + } else if (state == GnssConnectionState.disconnected || + state == GnssConnectionState.error) { + if (isConnected.value) { + unawaited(disconnect()); + } + } + }); + } + + void _ensureReceiverReady() { + if (!Get.isRegistered() || + GnssService.to.connectionState.value != GnssConnectionState.connected) { + throw NtripException( + 'Nincs csatlakoztatott GNSS vevő - előbb csatlakozz az eszközhöz, ' + 'utána indítható az NTRIP korrekció.'); + } + final type = GnssService.to.activeConnectionType.value; + if (type == GnssConnectionType.phoneGps) { + throw NtripException( + 'A telefon beépített GPS-e nem tud RTK korrekciókat fogadni - ' + 'az NTRIP használatához külső GNSS vevő szükséges'); } } @override void onClose() { disconnect(); - hostController.dispose(); - portController.dispose(); - mountpointController.dispose(); - usernameController.dispose(); - passwordController.dispose(); + // hostController.dispose(); + // portController.dispose(); + // mountpointController.dispose(); + // usernameController.dispose(); + // passwordController.dispose(); super.onClose(); } @@ -101,11 +145,14 @@ class NtripService extends GetxService { /// Kapcsolódás a casterhez. Megvárja a caster válaszát, és /// [NtripException]-t dob értelmes üzenettel, ha valami nem stimmel /// (rossz jelszó, rossz mountpoint, időtúllépés, hálózati hiba). - Future connect() async { + Future connect({bool bypassReceiverCheck = false}) async { if (isConnected.value) return; if (!hasCompleteSettings) { throw NtripException('Hiányzó NTRIP beállítások (host / mountpoint).'); } + if (!bypassReceiverCheck) { + _ensureReceiverReady(); + } lastError.value = ''; _headerValidated = false; @@ -140,6 +187,7 @@ class NtripService extends GetxService { ); isConnected.value = true; + connectedSince.value = DateTime.now(); } on NtripException { await _teardown(); rethrow; @@ -165,6 +213,8 @@ class NtripService extends GetxService { _socket?.destroy(); _socket = null; isConnected.value = false; + connectedSince.value = null; + lastRtcmAt.value = null; _headerValidated = false; _headerBytes.clear(); } @@ -175,6 +225,19 @@ class NtripService extends GetxService { await connect(); } + Future testConnection() async { + if (isConnected.value) { + throw NtripException('Már van élő NTRIP kapcsolat.'); + } + try { + await connect(bypassReceiverCheck: true); + return 'A caster elfogadta a kapcsolatot - ' + 'a beállítások (jelszó, mountpoint) rendben vannak.'; + } finally { + await disconnect(); + } + } + // ── GGA küldés ─────────────────────────────────────────────────── /// Az NMEA feldolgozó hívja minden GGA mondatnál. @@ -365,7 +428,7 @@ class NtripService extends GetxService { this.password.value = password; if (autoConnect != null) this.autoConnect.value = autoConnect; - _syncControllersFromValues(); + // _syncControllersFromValues(); final prefs = await SharedPreferences.getInstance(); await prefs.setString('ntrip_host', this.host.value); @@ -380,17 +443,17 @@ class NtripService extends GetxService { await prefs.remove('ntrip_password'); } - Future saveSettings() async { - await updateSettings( - host: hostController.text, - port: int.tryParse(portController.text) ?? 2101, - mountpoint: mountpointController.text, - username: usernameController.text, - password: passwordController.text.isNotEmpty - ? passwordController.text - : password.value, - ); - } + // Future saveSettings() async { + // await updateSettings( + // host: hostController.text, + // port: int.tryParse(portController.text) ?? 2101, + // mountpoint: mountpointController.text, + // username: usernameController.text, + // password: passwordController.text.isNotEmpty + // ? passwordController.text + // : password.value, + // ); + // } Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); @@ -402,13 +465,13 @@ class NtripService extends GetxService { autoConnect.value = prefs.getBool('ntrip_auto_connect') ?? false; } - void _syncControllersFromValues() { - hostController.text = host.value; - portController.text = port.value.toString(); - mountpointController.text = mountpoint.value; - usernameController.text = username.value; - // Jelszót nem pre-töltjük biztonsági okokból - } + // void _syncControllersFromValues() { + // hostController.text = host.value; + // portController.text = port.value.toString(); + // mountpointController.text = mountpoint.value; + // usernameController.text = username.value; + // // Jelszót nem pre-töltjük biztonsági okokból + // } // ── Segédfüggvények ────────────────────────────────────────────── diff --git a/lib/widgets/appbar/shell_map_appbar.dart b/lib/widgets/appbar/shell_map_appbar.dart index 3f460b1..9f51fa8 100644 --- a/lib/widgets/appbar/shell_map_appbar.dart +++ b/lib/widgets/appbar/shell_map_appbar.dart @@ -12,6 +12,7 @@ import 'package:terepi_seged/services/ntrip_service.dart'; import 'package:terepi_seged/widgets/appbar/gnss_status_strip.dart'; import 'package:terepi_seged/widgets/gnss_status_chip.dart'; import 'package:terepi_seged/widgets/map_mode_menu_anchor.dart'; +import 'package:terepi_seged/widgets/ntrip_status_sheet.dart'; import 'package:terepi_seged/widgets/tracking/track_recording_action.dart'; import '../tracking/tracking_sheet.dart'; @@ -103,9 +104,7 @@ class ShellMapAppBar extends StatelessWidget implements PreferredSizeWidget { NtripIconStatusChip( isConnected: NtripService.to.isConnected, onToggle: () { - NtripService.to.isConnected.value - ? NtripService.to.disconnect() - : NtripService.to.connect(); + NtripStatusSheet.show(); }, onSettings: () { HapticFeedback.mediumImpact(); diff --git a/lib/widgets/ntrip_status_sheet.dart b/lib/widgets/ntrip_status_sheet.dart new file mode 100644 index 0000000..1bcabf7 --- /dev/null +++ b/lib/widgets/ntrip_status_sheet.dart @@ -0,0 +1,478 @@ +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'; +}