From bc9e37bd220df9571a30ffe229b185e804b7023e Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 19 Mar 2026 10:33:20 +0100 Subject: [PATCH] fix: Count RX data and speed up Flutter builds #123 --- .github/workflows/build-artifacts.yml | 18 +++ .github/workflows/build-multiplatform.yml | 3 + lib/screens/live_traffic_screen.dart | 115 +++++++++++-------- lib/services/live_traffic_summary.dart | 37 ++++-- test/screens/live_traffic_screen_test.dart | 68 +++++++++-- test/services/live_traffic_summary_test.dart | 60 +++++++--- 6 files changed, 218 insertions(+), 83 deletions(-) diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 3542020..606003e 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -40,6 +40,9 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true + pub-cache: true + cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }} + pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }} - name: Cache Android NDK uses: actions/cache@v4 @@ -95,6 +98,9 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true + pub-cache: true + cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }} + pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }} - name: Enable Linux desktop run: flutter config --enable-linux-desktop @@ -132,6 +138,9 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true + pub-cache: true + cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }} + pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }} - name: Enable Windows desktop shell: pwsh @@ -174,6 +183,9 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true + pub-cache: true + cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }} + pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }} - name: Enable macOS desktop run: flutter config --enable-macos-desktop @@ -216,6 +228,9 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true + pub-cache: true + cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }} + pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }} - name: Install dependencies run: flutter pub get @@ -272,6 +287,9 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable cache: true + pub-cache: true + cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }} + pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }} - name: Enable web run: flutter config --enable-web diff --git a/.github/workflows/build-multiplatform.yml b/.github/workflows/build-multiplatform.yml index c0d7f10..892a483 100644 --- a/.github/workflows/build-multiplatform.yml +++ b/.github/workflows/build-multiplatform.yml @@ -27,6 +27,9 @@ jobs: flutter-version: ${{ env.FLUTTER_VERSION }} channel: 'stable' cache: true + pub-cache: true + cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }} + pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }} - name: Install dependencies run: flutter pub get diff --git a/lib/screens/live_traffic_screen.dart b/lib/screens/live_traffic_screen.dart index 90907cc..28f8f7c 100644 --- a/lib/screens/live_traffic_screen.dart +++ b/lib/screens/live_traffic_screen.dart @@ -27,7 +27,6 @@ T? _maybeProvider(BuildContext context) { class LiveTrafficScreen extends StatefulWidget { final List Function() logReader; - final int Function()? rxCountReader; final Listenable? refreshListenable; final DateTime Function() now; final VoidCallback? openPacketLogs; @@ -35,7 +34,6 @@ class LiveTrafficScreen extends StatefulWidget { const LiveTrafficScreen({ super.key, required this.logReader, - this.rxCountReader, this.refreshListenable, DateTime Function()? now, this.openPacketLogs, @@ -49,7 +47,6 @@ class LiveTrafficScreen extends StatefulWidget { return LiveTrafficScreen( key: key, logReader: () => provider.bleService.packetLogs, - rxCountReader: () => provider.rxPacketCount, refreshListenable: provider, openPacketLogs: openPacketLogs, ); @@ -118,15 +115,17 @@ class _LiveTrafficScreenState extends State { @override Widget build(BuildContext context) { + final logs = widget.logReader(); + final totalRxDataCount = LiveTrafficSummary.countRxDataLogs(logs); final unfilteredSnapshot = LiveTrafficSummary.fromLogs( - widget.logReader(), + logs, now: widget.now(), clearedAt: _clearedAt, preferredHashSize: _preferredHashSize, window: _selectedWindow, ); final snapshot = LiveTrafficSummary.fromLogs( - widget.logReader(), + logs, now: widget.now(), clearedAt: _clearedAt, preferredHashSize: _preferredHashSize, @@ -177,7 +176,7 @@ class _LiveTrafficScreenState extends State { children: [ _SummaryPanel( snapshot: snapshot, - totalRxCount: widget.rxCountReader?.call(), + totalRxCount: totalRxDataCount, routeHashCounts: routeHashCounts, onWindowTap: () => _showWindowPicker(context), ), @@ -306,8 +305,48 @@ class _SummaryPanel extends StatelessWidget { LiveTrafficBusyness.active => 'Active', LiveTrafficBusyness.busy => 'Busy', }; + final metrics = [ + _MetricTile( + tileKey: const ValueKey('liveTrafficMetric:rxPackets'), + label: AppLocalizations.of(context)!.rxPackets, + value: '${snapshot.rxCount}', + subtitle: totalRxCount == null + ? _windowSummaryLabel(snapshot.windowDuration) + : 'Device total $totalRxCount', + ), + _MetricTile( + tileKey: const ValueKey('liveTrafficMetric:rssi'), + label: AppLocalizations.of(context)!.rssi, + value: snapshot.latestRssiDbm == null + ? 'No RX data' + : '${snapshot.latestRssiDbm} dBm', + subtitle: snapshot.avgRssiDbm == null + ? 'No average yet' + : 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm', + ), + _MetricTile( + tileKey: const ValueKey('liveTrafficMetric:snr'), + label: AppLocalizations.of(context)!.snr, + value: snapshot.latestSnrDb == null + ? 'No RX data' + : '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB', + subtitle: snapshot.avgSnrDb == null + ? 'No average yet' + : 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB', + ), + _MetricTile( + tileKey: const ValueKey('liveTrafficMetric:multihop'), + label: AppLocalizations.of(context)!.multihop, + value: '${snapshot.multiHopCount}', + subtitle: routeHashCounts.summaryLabel, + footer: snapshot.avgHopCount == null + ? 'No routes yet' + : 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops', + ), + ]; return Container( + width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( gradient: LinearGradient( @@ -346,44 +385,26 @@ class _SummaryPanel extends StatelessWidget { ], ), const SizedBox(height: 14), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - _MetricTile( - label: AppLocalizations.of(context)!.rxPackets, - value: '${snapshot.rxCount}', - subtitle: totalRxCount == null - ? _windowSummaryLabel(snapshot.windowDuration) - : 'Device total $totalRxCount', - ), - _MetricTile( - label: AppLocalizations.of(context)!.rssi, - value: snapshot.latestRssiDbm == null - ? 'No RX data' - : '${snapshot.latestRssiDbm} dBm', - subtitle: snapshot.avgRssiDbm == null - ? 'No average yet' - : 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm', - ), - _MetricTile( - label: AppLocalizations.of(context)!.snr, - value: snapshot.latestSnrDb == null - ? 'No RX data' - : '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB', - subtitle: snapshot.avgSnrDb == null - ? 'No average yet' - : 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB', - ), - _MetricTile( - label: AppLocalizations.of(context)!.multihop, - value: '${snapshot.multiHopCount}', - subtitle: routeHashCounts.summaryLabel, - footer: snapshot.avgHopCount == null - ? 'No routes yet' - : 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops', - ), - ], + LayoutBuilder( + builder: (context, constraints) { + const spacing = 10.0; + const minTileWidth = 150.0; + final columns = + ((constraints.maxWidth + spacing) / (minTileWidth + spacing)) + .floor() + .clamp(1, metrics.length); + final tileWidth = + (constraints.maxWidth - (spacing * (columns - 1))) / columns; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final metric in metrics) + SizedBox(width: tileWidth, child: metric), + ], + ); + }, ), ], ), @@ -525,12 +546,14 @@ String _windowSummaryLabel(Duration duration) { } class _MetricTile extends StatelessWidget { + final Key? tileKey; final String label; final String value; final String subtitle; final String? footer; const _MetricTile({ + this.tileKey, required this.label, required this.value, required this.subtitle, @@ -540,7 +563,7 @@ class _MetricTile extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - width: 160, + key: tileKey, height: 136, padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -949,7 +972,6 @@ class _LiveTrafficCard extends StatelessWidget { ), ); } - } class _LiveTrafficPacketDetails { @@ -1105,7 +1127,6 @@ class _PacketInfoLine extends StatelessWidget { } } - class _GeoPoint { final double latitude; final double longitude; diff --git a/lib/services/live_traffic_summary.dart b/lib/services/live_traffic_summary.dart index 38c4816..5c42bf7 100644 --- a/lib/services/live_traffic_summary.dart +++ b/lib/services/live_traffic_summary.dart @@ -141,6 +141,18 @@ class LiveTrafficSummary { const LiveTrafficSummary._(); + static bool isRxDataLog(BlePacketLog log) { + return log.direction == PacketDirection.rx && + log.responseCode == logRxDataResponseCode; + } + + static int countRxDataLogs(Iterable logs, {DateTime? since}) { + return logs.where((log) { + return isRxDataLog(log) && + (since == null || !log.timestamp.isBefore(since)); + }).length; + } + static LiveTrafficSnapshot fromLogs( Iterable logs, { required DateTime now, @@ -154,15 +166,14 @@ class LiveTrafficSummary { ? clearedAt : windowStart; - final recentLogs = logs - .where( - (log) => - log.direction == PacketDirection.rx && - log.responseCode == logRxDataResponseCode && - !log.timestamp.isBefore(effectiveStart), - ) - .toList() - ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final recentLogs = + logs + .where( + (log) => + isRxDataLog(log) && !log.timestamp.isBefore(effectiveStart), + ) + .toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); final entries = []; for (final log in recentLogs) { @@ -215,7 +226,9 @@ class LiveTrafficSummary { } } - final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList(); + final visibleEntries = filteredEntries.reversed + .take(maxVisibleEntries) + .toList(); const txCount = 0; final totalCount = rxCount; final packetsPerMinute = @@ -233,7 +246,9 @@ class LiveTrafficSummary { avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount, latestRssiDbm: latestRssiDbm, multiHopCount: multiHopCount, - avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples, + avgHopCount: hopCountSamples == 0 + ? null + : hopCountTotal / hopCountSamples, visibleEntries: visibleEntries, busyness: _busynessForPacketsPerMinute(packetsPerMinute), ); diff --git a/test/screens/live_traffic_screen_test.dart b/test/screens/live_traffic_screen_test.dart index e8701aa..6e18ae0 100644 --- a/test/screens/live_traffic_screen_test.dart +++ b/test/screens/live_traffic_screen_test.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/models/ble_packet_log.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/screens/live_traffic_screen.dart'; BlePacketLog _log({ @@ -50,6 +51,14 @@ List _multiHopRaw({ ]; } +Widget _testApp(Widget child) { + return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ); +} + void main() { testWidgets('shows empty state before traffic arrives', (tester) async { final logs = []; @@ -57,8 +66,8 @@ void main() { DateTime now = DateTime(2026, 3, 12, 12, 0, 0); await tester.pumpWidget( - MaterialApp( - home: LiveTrafficScreen( + _testApp( + LiveTrafficScreen( logReader: () => logs, refreshListenable: refresh, now: () => now, @@ -78,10 +87,9 @@ void main() { DateTime now = DateTime(2026, 3, 12, 12, 0, 0); await tester.pumpWidget( - MaterialApp( - home: LiveTrafficScreen( + _testApp( + LiveTrafficScreen( logReader: () => logs, - rxCountReader: () => 7, refreshListenable: refresh, now: () => now, ), @@ -114,10 +122,9 @@ void main() { await tester.pump(); expect(find.text('1 pkt/min'), findsOneWidget); - expect(find.text('Device total 7'), findsOneWidget); + expect(find.text('Device total 1'), findsOneWidget); expect(find.text('FLOOD RESPONSE'), findsOneWidget); - expect(find.text('MULTI-HOP'), findsOneWidget); - expect(find.textContaining('RSSI -84 dBm'), findsOneWidget); + expect(find.text('-84 dBm'), findsOneWidget); expect(find.textContaining('Hash:'), findsOneWidget); expect( find.textContaining('Path: 3 hops [c010,6301,68d9]'), @@ -143,8 +150,8 @@ void main() { ); await tester.pumpWidget( - MaterialApp( - home: LiveTrafficScreen( + _testApp( + LiveTrafficScreen( logReader: () => logs, refreshListenable: refresh, now: () => now, @@ -152,7 +159,7 @@ void main() { ), ); - expect(find.text('MULTI-HOP'), findsOneWidget); + expect(find.text('FLOOD RESPONSE'), findsOneWidget); await tester.tap(find.byTooltip('Clear live view')); await tester.pump(); @@ -182,4 +189,43 @@ void main() { expect(find.text('No packets for this filter'), findsNothing); expect(find.textContaining('Size: 3 bytes'), findsOneWidget); }); + + testWidgets('summary metrics expand across wide layouts', (tester) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final now = DateTime(2026, 3, 12, 12, 0, 0); + final logs = [ + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.rx, + rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]), + responseCode: 0x88, + snrDb: 9.5, + rssiDbm: -82, + ), + ]; + + await tester.pumpWidget( + _testApp(LiveTrafficScreen(logReader: () => logs, now: () => now)), + ); + + expect( + tester + .getSize( + find.byKey(const ValueKey('liveTrafficMetric:rxPackets')), + ) + .width > + 200, + isTrue, + ); + expect( + tester + .getSize(find.byKey(const ValueKey('liveTrafficMetric:multihop'))) + .width > + 200, + isTrue, + ); + }); } diff --git a/test/services/live_traffic_summary_test.dart b/test/services/live_traffic_summary_test.dart index 4479104..816d0ec 100644 --- a/test/services/live_traffic_summary_test.dart +++ b/test/services/live_traffic_summary_test.dart @@ -146,23 +146,55 @@ void main() { test('supports clearing the live view without mutating source logs', () { final now = DateTime(2026, 3, 12, 12, 0, 0); final clearAt = now.subtract(const Duration(seconds: 8)); - final snapshot = LiveTrafficSummary.fromLogs([ - _log( - timestamp: now.subtract(const Duration(seconds: 10)), - direction: PacketDirection.rx, - rawData: [0x88, 0x00, 0x00], - responseCode: 0x88, - ), - _log( - timestamp: now.subtract(const Duration(seconds: 4)), - direction: PacketDirection.rx, - rawData: [0x88, 0x00, 0x00], - responseCode: 0x88, - ), - ], now: now, clearedAt: clearAt); + final snapshot = LiveTrafficSummary.fromLogs( + [ + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 4)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + ], + now: now, + clearedAt: clearAt, + ); expect(snapshot.totalCount, 1); expect(snapshot.visibleEntries, hasLength(1)); }); + + test('counts only RX data packets for device totals', () { + final now = DateTime(2026, 3, 12, 12, 0, 0); + + expect( + LiveTrafficSummary.countRxDataLogs([ + _log( + timestamp: now.subtract(const Duration(seconds: 30)), + direction: PacketDirection.rx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 20)), + direction: PacketDirection.tx, + rawData: [0x88, 0x00, 0x00], + responseCode: 0x88, + ), + _log( + timestamp: now.subtract(const Duration(seconds: 10)), + direction: PacketDirection.rx, + rawData: [0x05, 0x01, 0x02], + responseCode: 0x05, + ), + ]), + 1, + ); + }); }); }