Files
MobilApp/lib/widgets/ntrip_status_sheet.dart
T

479 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> show() {
return Get.bottomSheet(
const NtripStatusSheet(),
isScrollControlled: true,
);
}
@override
State<NtripStatusSheet> createState() => _NtripStatusSheetState();
}
class _NtripStatusSheetState extends State<NtripStatusSheet> {
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<void> _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, 210 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<GnssService>()) 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';
}