feat: Add shadcn worker UI

This commit is contained in:
Janez T
2026-04-03 19:46:51 +02:00
parent 724f4b38e8
commit 7c1bc3b84f
21 changed files with 3709 additions and 2 deletions

View File

@@ -19,6 +19,7 @@ import '../services/messaging_route_preferences.dart';
import '../services/nearest_router_selector.dart';
import '../services/packet_capture_storage_service.dart';
import '../services/path_history_service.dart';
import '../services/traffic_stats_reporting_service.dart';
import '../utils/rssi_location_estimator.dart';
import '../services/profiles_feature_service.dart';
import '../services/route_hash_preferences.dart';
@@ -159,6 +160,8 @@ class AppProvider with ChangeNotifier {
LocationTrackingService();
final PacketCaptureStorageService packetCaptureStorageService =
PacketCaptureStorageService();
final TrafficStatsReportingService trafficStatsReportingService =
TrafficStatsReportingService();
final NotificationService _notificationService = NotificationService();
bool _isInitialized = false;
@@ -264,6 +267,11 @@ class AppProvider with ChangeNotifier {
_loadVoiceNoiseSuppressionEnabled();
_loadMessageFontScale();
_loadMessagingRouteSettings();
unawaited(
trafficStatsReportingService.initialize(
deviceKey6Provider: _deviceKey6Hex,
),
);
unawaited(_pathHistoryService.initialize());
_startPacketCapturePersistence();
_startLowBatteryWatcher();
@@ -397,6 +405,7 @@ class AppProvider with ChangeNotifier {
if (toPersist.isNotEmpty) {
await packetCaptureStorageService.appendLogs(toPersist);
await trafficStatsReportingService.processLogs(toPersist);
}
_lastPersistedPacketSignature = _packetLogSignature(logs.last);
} catch (e) {
@@ -4135,6 +4144,7 @@ class AppProvider with ChangeNotifier {
}
_pendingChannelVoicePackets.clear();
_pendingChannelImageFragments.clear();
trafficStatsReportingService.dispose();
super.dispose();
}
}

View File

@@ -3,6 +3,7 @@ import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/ble_packet_log.dart';
import '../models/contact.dart';
@@ -12,6 +13,7 @@ import '../providers/connection_provider.dart';
import '../services/live_traffic_summary.dart';
import '../services/location_tracking_service.dart';
import '../services/route_hash_preferences.dart';
import '../services/traffic_stats_reporting_service.dart';
import '../utils/log_rx_route_decoder.dart';
import '../widgets/compact_signal_indicator.dart';
import '../widgets/messages/message_trace_sheet.dart';
@@ -156,6 +158,11 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
tooltip: 'Open packet logs',
icon: const Icon(Icons.list_alt_rounded),
),
IconButton(
onPressed: _openStatsDashboard,
tooltip: 'View public stats',
icon: const Icon(Icons.open_in_new),
),
IconButton(
onPressed: () => _showPacketTypeHelpSheet(context),
tooltip: 'Packet type help',
@@ -256,6 +263,13 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
);
}
Future<void> _openStatsDashboard() async {
final url = TrafficStatsReportingService.dashboardUri;
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
}
Future<void> _showWindowPicker(BuildContext context) async {
final selected = await showModalBottomSheet<Duration>(
context: context,

View File

@@ -38,6 +38,7 @@ import '../utils/voice_message_parser.dart';
import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart';
import '../widgets/update_dialog.dart';
import '../widgets/settings/traffic_stats_reporting_section.dart';
import 'sar_template_management_screen.dart';
import 'profiles_screen.dart';
import 'welcome_wizard_screen.dart';
@@ -2162,6 +2163,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
_buildSectionHeader('Developer & Data'),
_buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => ListenableBuilder(
listenable: appProvider.trafficStatsReportingService,
builder: (context, child) => TrafficStatsReportingSection(
service: appProvider.trafficStatsReportingService,
),
),
),
ListTile(
leading: Icon(Icons.bug_report),
title: Text(AppLocalizations.of(context)!.packageName),

View File

@@ -0,0 +1,507 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/ble_packet_log.dart';
import '../utils/log_rx_route_decoder.dart';
import 'live_traffic_summary.dart';
class TrafficStatsCounts {
static const List<String> packetTypeKeys = <String>[
'pt_00',
'pt_01',
'pt_02',
'pt_03',
'pt_04',
'pt_05',
'pt_06',
'pt_07',
'pt_08',
'pt_09',
'pt_0a',
'pt_0b',
'pt_0c',
'pt_0d',
'pt_0e',
'pt_0f',
];
static const List<String> pathModeKeys = <String>[
'path_mode_1b',
'path_mode_2b',
'path_mode_3b',
'path_mode_none',
'path_mode_unknown',
];
static const String decodeFailKey = 'decode_fail';
static const List<String> allKeys = <String>[
...packetTypeKeys,
decodeFailKey,
...pathModeKeys,
];
final Map<String, int> _values;
TrafficStatsCounts._(this._values);
factory TrafficStatsCounts.empty() {
return TrafficStatsCounts._(
<String, int>{for (final key in allKeys) key: 0},
);
}
factory TrafficStatsCounts.fromJson(Map<String, dynamic>? json) {
final counts = TrafficStatsCounts.empty();
if (json == null) {
return counts;
}
for (final key in allKeys) {
counts._values[key] = (json[key] as num?)?.toInt() ?? 0;
}
return counts;
}
int operator [](String key) => _values[key] ?? 0;
bool get isEmpty => _values.values.every((value) => value == 0);
Map<String, int> toJson() {
return <String, int>{
for (final key in allKeys) key: _values[key] ?? 0,
};
}
void increment(String key, [int amount = 1]) {
_values[key] = (_values[key] ?? 0) + amount;
}
void incrementPacketType(int payloadType) {
if (payloadType < 0 || payloadType > 0x0F) {
increment(decodeFailKey);
return;
}
increment('pt_${payloadType.toRadixString(16).padLeft(2, '0')}');
}
void mergeFrom(TrafficStatsCounts other) {
for (final key in allKeys) {
increment(key, other[key]);
}
}
}
class TrafficStatsQueuedReport {
final String reportId;
final String deviceKey6;
final DateTime windowStart;
final DateTime windowEnd;
final String appVersion;
final TrafficStatsCounts counts;
const TrafficStatsQueuedReport({
required this.reportId,
required this.deviceKey6,
required this.windowStart,
required this.windowEnd,
required this.appVersion,
required this.counts,
});
factory TrafficStatsQueuedReport.fromJson(Map<String, dynamic> json) {
return TrafficStatsQueuedReport(
reportId: (json['reportId'] as String?) ?? '',
deviceKey6: (json['deviceKey6'] as String?) ?? '',
windowStart: DateTime.parse(json['windowStart'] as String).toUtc(),
windowEnd: DateTime.parse(json['windowEnd'] as String).toUtc(),
appVersion: (json['appVersion'] as String?) ?? 'unknown',
counts: TrafficStatsCounts.fromJson(
json['counts'] as Map<String, dynamic>?,
),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'reportId': reportId,
'deviceKey6': deviceKey6,
'windowStart': windowStart.toIso8601String(),
'windowEnd': windowEnd.toIso8601String(),
'appVersion': appVersion,
'counts': counts.toJson(),
};
}
}
class TrafficStatsReportingService extends ChangeNotifier {
static const String workerBaseUrl = 'https://mcstats.dz0ny.dev';
static final Uri dashboardUri = Uri.parse(workerBaseUrl);
static final Uri ingestUri = Uri.parse('$workerBaseUrl/api/ingest');
static const int defaultIntervalMinutes = 5;
static const String _enabledKey = 'traffic_stats_reporting_enabled';
static const String _legacyIntervalKey =
'traffic_stats_reporting_interval_minutes';
static const String _queueKey = 'traffic_stats_reporting_queue';
static const String _lastSuccessAtKey =
'traffic_stats_reporting_last_success_at';
static const String _lastErrorKey = 'traffic_stats_reporting_last_error';
static const Duration _retryInterval = Duration(seconds: 30);
final http.Client _client;
final bool _ownsClient;
final DateTime Function() _now;
final Future<SharedPreferences> Function() _prefsProvider;
final Future<String> Function() _appVersionProvider;
final Map<int, TrafficStatsCounts> _openWindows =
<int, TrafficStatsCounts>{};
final List<TrafficStatsQueuedReport> _queue = <TrafficStatsQueuedReport>[];
String? Function()? _deviceKey6Provider;
Timer? _retryTimer;
String? _appVersion;
bool _enabled = false;
DateTime? _lastSuccessAt;
String? _lastError;
bool _isInitialized = false;
bool _isFlushingQueue = false;
TrafficStatsReportingService({
http.Client? client,
DateTime Function()? now,
Future<SharedPreferences> Function()? prefsProvider,
Future<String> Function()? appVersionProvider,
}) : _client = client ?? http.Client(),
_ownsClient = client == null,
_now = now ?? DateTime.now,
_prefsProvider = prefsProvider ?? SharedPreferences.getInstance,
_appVersionProvider = appVersionProvider ?? _defaultAppVersionProvider;
bool get isEnabled => _enabled;
int get intervalMinutes => defaultIntervalMinutes;
DateTime? get lastSuccessAt => _lastSuccessAt;
String? get lastError => _lastError;
bool get isInitialized => _isInitialized;
bool get isFlushingQueue => _isFlushingQueue;
int get pendingUploadCount => _queue.length;
Future<void> initialize({
required String? Function() deviceKey6Provider,
}) async {
_deviceKey6Provider = deviceKey6Provider;
final prefs = await _prefsProvider();
_enabled = prefs.getBool(_enabledKey) ?? false;
if (prefs.containsKey(_legacyIntervalKey)) {
await prefs.remove(_legacyIntervalKey);
}
final queueJson = prefs.getString(_queueKey);
if (queueJson != null && queueJson.isNotEmpty) {
final decoded = jsonDecode(queueJson);
if (decoded is List) {
_queue
..clear()
..addAll(
decoded.whereType<Map<String, dynamic>>().map(
TrafficStatsQueuedReport.fromJson,
),
);
}
}
final lastSuccessAt = prefs.getString(_lastSuccessAtKey);
if (lastSuccessAt != null && lastSuccessAt.isNotEmpty) {
_lastSuccessAt = DateTime.tryParse(lastSuccessAt)?.toUtc();
}
final lastError = prefs.getString(_lastErrorKey);
if (lastError != null && lastError.isNotEmpty) {
_lastError = lastError;
}
try {
_appVersion = await _appVersionProvider();
} catch (_) {
_appVersion = 'unknown';
}
_retryTimer?.cancel();
_retryTimer = Timer.periodic(_retryInterval, (_) {
unawaited(flushPendingUploads());
});
_isInitialized = true;
notifyListeners();
await flushPendingUploads();
}
Future<void> setEnabled(bool enabled) async {
if (_enabled == enabled) {
return;
}
_enabled = enabled;
_lastError = null;
if (!enabled) {
_openWindows.clear();
}
await _saveState();
notifyListeners();
if (enabled) {
unawaited(flushPendingUploads());
}
}
Future<void> processLogs(List<BlePacketLog> logs) async {
if (!_enabled || logs.isEmpty) {
if (_enabled) {
await flushPendingUploads();
}
return;
}
var changed = false;
for (final log in logs) {
if (!LiveTrafficSummary.isRxDataLog(log)) {
continue;
}
final counts = _openWindows.putIfAbsent(
_windowStartFor(log.timestamp).millisecondsSinceEpoch,
TrafficStatsCounts.empty,
);
final route = LogRxRouteDecoder.decode(log.rawData);
if (route == null) {
counts.increment(TrafficStatsCounts.decodeFailKey);
} else {
counts.incrementPacketType(route.payloadType);
}
counts.increment(_pathModeKeyFor(log.rawData, route));
changed = true;
}
if (!changed) {
await flushPendingUploads();
return;
}
final queuedReports = _queueClosedWindows();
if (queuedReports > 0) {
await _saveState();
}
notifyListeners();
await flushPendingUploads();
}
Future<void> flushPendingUploads() async {
if (!_enabled || _queue.isEmpty || _isFlushingQueue) {
return;
}
final deviceKey6 = _deviceKey6Provider?.call();
if (deviceKey6 == null || deviceKey6.isEmpty) {
return;
}
_isFlushingQueue = true;
notifyListeners();
try {
while (_queue.isNotEmpty && _enabled) {
final report = _queue.first;
final response = await _client.post(
ingestUri,
headers: const <String, String>{
'content-type': 'application/json',
},
body: jsonEncode(report.toJson()),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
_lastError = 'Upload failed (${response.statusCode})';
await _saveState();
notifyListeners();
return;
}
_queue.removeAt(0);
_lastError = null;
_lastSuccessAt = _now().toUtc();
await _saveState();
notifyListeners();
}
} catch (error) {
_lastError = 'Upload failed: $error';
await _saveState();
notifyListeners();
} finally {
_isFlushingQueue = false;
notifyListeners();
}
}
int _queueClosedWindows() {
final deviceKey6 = _deviceKey6Provider?.call();
if (deviceKey6 == null || deviceKey6.isEmpty) {
return 0;
}
final now = _now().toUtc();
final closable = _openWindows.keys
.where((windowStartMs) {
final windowStart = DateTime.fromMillisecondsSinceEpoch(
windowStartMs,
isUtc: true,
);
final windowEnd = windowStart.add(
Duration(minutes: defaultIntervalMinutes),
);
return !windowEnd.isAfter(now);
})
.toList()
..sort();
for (final windowStartMs in closable) {
final windowStart = DateTime.fromMillisecondsSinceEpoch(
windowStartMs,
isUtc: true,
);
final counts = _openWindows.remove(windowStartMs);
if (counts == null || counts.isEmpty) {
continue;
}
final reportId = '$deviceKey6:${windowStart.toIso8601String()}';
final existingIndex = _queue.indexWhere(
(report) => report.reportId == reportId,
);
if (existingIndex != -1) {
_queue[existingIndex].counts.mergeFrom(counts);
continue;
}
_queue.add(
TrafficStatsQueuedReport(
reportId: reportId,
deviceKey6: deviceKey6,
windowStart: windowStart,
windowEnd: windowStart.add(
Duration(minutes: defaultIntervalMinutes),
),
appVersion: _appVersion ?? 'unknown',
counts: counts,
),
);
}
return closable.length;
}
DateTime _windowStartFor(DateTime timestamp) {
final utc = timestamp.toUtc();
final alignedMinute =
utc.minute - (utc.minute % defaultIntervalMinutes);
return DateTime.utc(
utc.year,
utc.month,
utc.day,
utc.hour,
alignedMinute,
);
}
String _pathModeKeyFor(Uint8List rawData, DecodedLogRxRoute? route) {
if (route != null) {
if (route.pathBytes.isEmpty) {
return 'path_mode_none';
}
switch (route.hashSize) {
case 1:
return 'path_mode_1b';
case 2:
return 'path_mode_2b';
case 3:
return 'path_mode_3b';
}
return 'path_mode_unknown';
}
return _pathModeKeyFromRawData(rawData);
}
String _pathModeKeyFromRawData(Uint8List rawData) {
if (rawData.length < 5 ||
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
return 'path_mode_unknown';
}
final rawPacketData = rawData.sublist(3);
if (rawPacketData.length < 2) {
return 'path_mode_unknown';
}
final header = rawPacketData[0];
final routeType = header & 0x03;
var index = 1;
if (routeType == 0x00 || routeType == 0x03) {
if (rawPacketData.length < index + 5) {
return 'path_mode_unknown';
}
index += 4;
}
if (rawPacketData.length <= index) {
return 'path_mode_unknown';
}
final pathDescriptor = rawPacketData[index];
final pathByteLen = LogRxRouteDecoder.descriptorByteLength(pathDescriptor);
if (pathByteLen == null) {
return 'path_mode_unknown';
}
if (rawPacketData.length < index + 1 + pathByteLen) {
return 'path_mode_unknown';
}
if (pathByteLen == 0) {
return 'path_mode_none';
}
final hashSize = LogRxRouteDecoder.descriptorHashSize(pathDescriptor);
switch (hashSize) {
case 1:
return 'path_mode_1b';
case 2:
return 'path_mode_2b';
case 3:
return 'path_mode_3b';
}
return 'path_mode_unknown';
}
Future<void> _saveState() async {
final prefs = await _prefsProvider();
await prefs.setBool(_enabledKey, _enabled);
await prefs.remove(_legacyIntervalKey);
await prefs.setString(
_queueKey,
jsonEncode(_queue.map((report) => report.toJson()).toList()),
);
if (_lastSuccessAt == null) {
await prefs.remove(_lastSuccessAtKey);
} else {
await prefs.setString(
_lastSuccessAtKey,
_lastSuccessAt!.toIso8601String(),
);
}
if (_lastError == null || _lastError!.isEmpty) {
await prefs.remove(_lastErrorKey);
} else {
await prefs.setString(_lastErrorKey, _lastError!);
}
}
static Future<String> _defaultAppVersionProvider() async {
final info = await PackageInfo.fromPlatform();
if (info.buildNumber.isEmpty || info.buildNumber == '0') {
return info.version;
}
return '${info.version}+${info.buildNumber}';
}
@override
void dispose() {
_retryTimer?.cancel();
if (_ownsClient) {
_client.close();
}
super.dispose();
}
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../services/traffic_stats_reporting_service.dart';
class TrafficStatsReportingSection extends StatelessWidget {
final TrafficStatsReportingService service;
const TrafficStatsReportingSection({super.key, required this.service});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
secondary: const Icon(Icons.cloud_upload_outlined),
title: const Text('Anonymous RX stats reporting'),
subtitle: const Text(
'Upload RX live-traffic packet type and path mode totals to the fixed Cloudflare worker every 5 minutes.',
),
value: service.isEnabled,
onChanged: (value) async {
await service.setEnabled(value);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Upload status',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
_statusText(service),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 6),
Text(
'Ingest URL: ${TrafficStatsReportingService.ingestUri}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _openStatsDashboard,
icon: const Icon(Icons.open_in_new),
label: const Text('View public stats'),
),
],
),
),
),
),
],
);
}
static Future<void> _openStatsDashboard() async {
final url = TrafficStatsReportingService.dashboardUri;
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
}
static String _statusText(TrafficStatsReportingService service) {
final buffer = StringBuffer();
buffer.write('Pending uploads: ${service.pendingUploadCount}');
if (service.lastSuccessAt != null) {
buffer.write(
'\nLast sent: ${_formatDateTime(service.lastSuccessAt!.toLocal())}',
);
} else {
buffer.write('\nLast sent: Never');
}
if (service.lastError != null && service.lastError!.isNotEmpty) {
buffer.write('\nLast error: ${service.lastError}');
} else {
buffer.write('\nLast error: None');
}
return buffer.toString();
}
static String _formatDateTime(DateTime value) {
final month = value.month.toString().padLeft(2, '0');
final day = value.day.toString().padLeft(2, '0');
final hour = value.hour.toString().padLeft(2, '0');
final minute = value.minute.toString().padLeft(2, '0');
final second = value.second.toString().padLeft(2, '0');
return '${value.year}-$month-$day $hour:$minute:$second';
}
}