479 lines
16 KiB
Dart
479 lines
16 KiB
Dart
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, 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<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';
|
||
}
|