From ee28d62f2e3f0f14d61381c07064f6d97830ccd1 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 17:30:59 +0530 Subject: [PATCH 01/23] feat: Added scaffold for myspeed service --- .../src/screens/service_detail_screen.dart | 2 ++ app/pubspec.yaml | 1 + app/test/new_services_render_test.dart | 13 +++++++ .../core_models/lib/src/service_kind.dart | 10 ++++-- .../core_models/test/service_kind_test.dart | 12 +++++++ .../lib/src/service_health.dart | 2 ++ packages/core_ui/lib/src/service_visuals.dart | 2 ++ pubspec.yaml | 1 + .../service_myspeed/lib/service_myspeed.dart | 1 + .../service_myspeed/lib/src/myspeed_home.dart | 25 ++++++++++++++ services/service_myspeed/pubspec.yaml | 22 ++++++++++++ .../test/myspeed_home_test.dart | 34 +++++++++++++++++++ 12 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 services/service_myspeed/lib/service_myspeed.dart create mode 100644 services/service_myspeed/lib/src/myspeed_home.dart create mode 100644 services/service_myspeed/pubspec.yaml create mode 100644 services/service_myspeed/test/myspeed_home_test.dart diff --git a/app/lib/src/screens/service_detail_screen.dart b/app/lib/src/screens/service_detail_screen.dart index 3e908146..6696b369 100644 --- a/app/lib/src/screens/service_detail_screen.dart +++ b/app/lib/src/screens/service_detail_screen.dart @@ -30,6 +30,7 @@ import 'package:service_unraid/service_unraid.dart'; import 'package:service_gluetun/service_gluetun.dart'; import 'package:service_navidrome/service_navidrome.dart'; import 'package:service_ombi/service_ombi.dart'; +import 'package:service_myspeed/service_myspeed.dart'; import 'dashboard_screen.dart'; @@ -336,6 +337,7 @@ class _ServiceDetailScreenState extends ConsumerState { ServiceKind.gluetun => GluetunHome(instance: instance), ServiceKind.navidrome => const SizedBox.shrink(), ServiceKind.ombi => OmbiHome(instance: instance), + ServiceKind.myspeed => MySpeedHome(instance: instance), }; } } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 0dc81e26..78b65fd0 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: service_gluetun: service_navidrome: service_ombi: + service_myspeed: url_launcher: ^6.3.2 dev_dependencies: flutter_launcher_icons: ^0.14.3 diff --git a/app/test/new_services_render_test.dart b/app/test/new_services_render_test.dart index 1852d0e9..9549cd29 100644 --- a/app/test/new_services_render_test.dart +++ b/app/test/new_services_render_test.dart @@ -18,6 +18,7 @@ import 'package:service_plex/service_plex.dart'; import 'package:service_radarr/service_radarr.dart'; import 'package:service_sabnzbd/service_sabnzbd.dart'; import 'package:service_tautulli/service_tautulli.dart'; +import 'package:service_myspeed/service_myspeed.dart'; import 'package:atrium/src/preferences.dart'; import 'package:atrium/src/screens/calendar_screen.dart'; @@ -399,4 +400,16 @@ void main() { expect(find.text('Radiohead - A Moon Shaped Pool'), findsOneWidget); expect(find.text('Downloaded'), findsOneWidget); }); + + testWidgets('MySpeedHome renders', (WidgetTester tester) async { + final Instance myspeed = _instance(ServiceKind.myspeed); + await _pump( + tester, + [], + MySpeedHome(instance: myspeed), + ); + + expect(find.text('MySpeed'), findsOneWidget); + expect(find.text('MySpeed service integration ready.'), findsOneWidget); + }); } diff --git a/packages/core_models/lib/src/service_kind.dart b/packages/core_models/lib/src/service_kind.dart index cdc3ad0b..ca396571 100644 --- a/packages/core_models/lib/src/service_kind.dart +++ b/packages/core_models/lib/src/service_kind.dart @@ -29,6 +29,7 @@ enum ServiceKind { navidrome, gluetun, ombi, + myspeed, } /// Static metadata about a [ServiceKind] - display name, default port, the @@ -63,6 +64,7 @@ extension ServiceKindX on ServiceKind { ServiceKind.gluetun => 'Gluetun', ServiceKind.navidrome => 'Navidrome', ServiceKind.ombi => 'Ombi', + ServiceKind.myspeed => 'MySpeed', }; /// One-line role description. @@ -92,6 +94,7 @@ extension ServiceKindX on ServiceKind { ServiceKind.gluetun => 'VPN client', ServiceKind.navidrome => 'Music server', ServiceKind.ombi => 'Requests', + ServiceKind.myspeed => 'Internet speed', }; /// Whether this service's integration is still in beta. Surfaced as a @@ -104,7 +107,8 @@ extension ServiceKindX on ServiceKind { ServiceKind.unraid || ServiceKind.gluetun || ServiceKind.navidrome || - ServiceKind.ombi => + ServiceKind.ombi || + ServiceKind.myspeed => true, _ => false, }; @@ -143,6 +147,7 @@ extension ServiceKindX on ServiceKind { // The linuxserver image, which most people run. Ombi's own default // is 5000. ServiceKind.ombi => 3579, + ServiceKind.myspeed => 5216, }; /// What auth flow the service uses by default. Some services (Jellyfin) can @@ -179,7 +184,7 @@ extension ServiceKindX on ServiceKind { ServiceKind.glances => AuthStyle.none, ServiceKind.speedtestTracker => AuthStyle.bearerToken, ServiceKind.beszel => AuthStyle.userPass, - ServiceKind.dashdot => AuthStyle.none, + ServiceKind.dashdot || ServiceKind.myspeed => AuthStyle.none, }; /// Broad role of the service in the stack - used for grouping in the @@ -211,6 +216,7 @@ extension ServiceKindX on ServiceKind { ServiceKind.dashdot => ServiceRole.analytics, ServiceKind.unraid => ServiceRole.analytics, ServiceKind.gluetun => ServiceRole.analytics, + ServiceKind.myspeed => ServiceRole.analytics, }; /// Whether this service can be handed a torrent - a magnet URI, a link to a diff --git a/packages/core_models/test/service_kind_test.dart b/packages/core_models/test/service_kind_test.dart index 3ef4b1e1..13642198 100644 --- a/packages/core_models/test/service_kind_test.dart +++ b/packages/core_models/test/service_kind_test.dart @@ -34,6 +34,7 @@ void main() { 'navidrome', 'gluetun', 'ombi', + 'myspeed', ], ); }); @@ -48,6 +49,16 @@ void main() { expect(ServiceKind.ombi.acceptsTorrents, isFalse); }); + test('MySpeed is registered as a beta no-auth analytics service', () { + expect(ServiceKind.myspeed.displayName, 'MySpeed'); + expect(ServiceKind.myspeed.tagline, 'Internet speed'); + expect(ServiceKind.myspeed.isBeta, isTrue); + expect(ServiceKind.myspeed.defaultPort, 5216); + expect(ServiceKind.myspeed.authStyle, AuthStyle.none); + expect(ServiceKind.myspeed.role, ServiceRole.analytics); + expect(ServiceKind.myspeed.acceptsTorrents, isFalse); + }); + test('Lidarr is registered as apiKey automation service', () { expect(ServiceKind.lidarr.displayName, 'Lidarr'); expect(ServiceKind.lidarr.role, ServiceRole.automation); @@ -92,6 +103,7 @@ void main() { expect(ServiceKind.tracearr.isBeta, isFalse); expect(ServiceKind.unraid.isBeta, isTrue); expect(ServiceKind.navidrome.isBeta, isTrue); + expect(ServiceKind.myspeed.isBeta, isTrue); }); test('Navidrome is registered as userPass mediaServer service', () { diff --git a/packages/core_networking/lib/src/service_health.dart b/packages/core_networking/lib/src/service_health.dart index 168374b4..dac8b06e 100644 --- a/packages/core_networking/lib/src/service_health.dart +++ b/packages/core_networking/lib/src/service_health.dart @@ -113,6 +113,8 @@ enum _HealthMode { // response is only worth anything once the envelope has been read; // see the navidrome arm of [interpretServiceHealthResponse]. return (path: 'rest/ping.view', mode: _HealthMode.authed); + case ServiceKind.myspeed: + return (path: 'api/speedtests', mode: _HealthMode.publicEndpoint); } } diff --git a/packages/core_ui/lib/src/service_visuals.dart b/packages/core_ui/lib/src/service_visuals.dart index 468ddc73..9bfbf147 100644 --- a/packages/core_ui/lib/src/service_visuals.dart +++ b/packages/core_ui/lib/src/service_visuals.dart @@ -33,6 +33,7 @@ abstract final class ServiceVisuals { ServiceKind.gluetun => Icons.shield_outlined, ServiceKind.navidrome => Icons.queue_music_rounded, ServiceKind.ombi => Icons.movie_filter_outlined, + ServiceKind.myspeed => Icons.network_check_outlined, }; static Color accent(ServiceKind kind) => switch (kind) { @@ -61,6 +62,7 @@ abstract final class ServiceVisuals { ServiceKind.gluetun => const Color(0xFF3B82F6), ServiceKind.navidrome => const Color(0xFF0086D6), ServiceKind.ombi => const Color(0xFFDF691A), + ServiceKind.myspeed => const Color(0xFF06B6D4), }; /// Human label for a [ServiceRole] section header. diff --git a/pubspec.yaml b/pubspec.yaml index 2176bbc7..7d6da826 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,3 +50,4 @@ workspace: - services/service_ombi - services/service_gluetun - services/service_navidrome + - services/service_myspeed diff --git a/services/service_myspeed/lib/service_myspeed.dart b/services/service_myspeed/lib/service_myspeed.dart new file mode 100644 index 00000000..d0af5c1b --- /dev/null +++ b/services/service_myspeed/lib/service_myspeed.dart @@ -0,0 +1 @@ +export 'src/myspeed_home.dart'; diff --git a/services/service_myspeed/lib/src/myspeed_home.dart b/services/service_myspeed/lib/src/myspeed_home.dart new file mode 100644 index 00000000..9e314e20 --- /dev/null +++ b/services/service_myspeed/lib/src/myspeed_home.dart @@ -0,0 +1,25 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// MySpeed service home screen. +class MySpeedHome extends ConsumerWidget { + const MySpeedHome({required this.instance, super.key}); + + final Instance instance; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Center( + child: Padding( + padding: Insets.page, + child: EmptyView( + icon: ServiceVisuals.icon(instance.kind), + title: 'MySpeed', + message: 'MySpeed service integration ready.', + ), + ), + ); + } +} diff --git a/services/service_myspeed/pubspec.yaml b/services/service_myspeed/pubspec.yaml new file mode 100644 index 00000000..b26dd3a8 --- /dev/null +++ b/services/service_myspeed/pubspec.yaml @@ -0,0 +1,22 @@ +name: service_myspeed +description: MySpeed API client and UI screens for Atrium. +publish_to: none +resolution: workspace + +environment: + sdk: ^3.6.0 + flutter: ^3.27.0 + +dependencies: + core_models: + core_networking: + core_ui: + dio: ^5.7.0 + flutter: + sdk: flutter + flutter_riverpod: ^3.3.2 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter diff --git a/services/service_myspeed/test/myspeed_home_test.dart b/services/service_myspeed/test/myspeed_home_test.dart new file mode 100644 index 00000000..d8e9ac04 --- /dev/null +++ b/services/service_myspeed/test/myspeed_home_test.dart @@ -0,0 +1,34 @@ +import 'package:core_models/core_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_myspeed/service_myspeed.dart'; + +void main() { + testWidgets('MySpeedHome renders placeholder empty view', + (WidgetTester tester) async { + const Instance instance = Instance( + id: 'test-myspeed', + name: 'MySpeed Test', + kind: ServiceKind.myspeed, + localUrl: 'http://localhost:5216', + externalUrl: '', + urlMode: UrlMode.auto, + auth: const InstanceAuth.apiKey(apiKey: ''), + ); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: Scaffold( + body: MySpeedHome(instance: instance), + ), + ), + ), + ); + + expect(find.text('MySpeed'), findsOneWidget); + expect(find.text('MySpeed service integration ready.'), findsOneWidget); + expect(find.byIcon(Icons.network_check_outlined), findsOneWidget); + }); +} From 7abaecda6188353377c1b2eb780ee4619f8098da Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 17:35:26 +0530 Subject: [PATCH 02/23] test(core_models): verify myspeed instance serialization --- .../core_models/test/profile_compat_test.dart | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/core_models/test/profile_compat_test.dart b/packages/core_models/test/profile_compat_test.dart index 8939c329..12789920 100644 --- a/packages/core_models/test/profile_compat_test.dart +++ b/packages/core_models/test/profile_compat_test.dart @@ -103,6 +103,26 @@ void main() { expect(decoded.kind, ServiceKind.speedtestTracker); }); + test('MySpeed instance round-trips by stable enum name', () { + const Instance instance = Instance( + id: 'myspeed-1', + name: 'Home MySpeed', + kind: ServiceKind.myspeed, + localUrl: 'http://192.168.1.10:5216', + externalUrl: '', + urlMode: UrlMode.auto, + auth: InstanceAuth.apiKey(apiKey: ''), + ); + + final Map json = jsonDecode( + jsonEncode(instance.toJson()), + ) as Map; + final Instance decoded = Instance.fromJson(json); + + expect(json['kind'], 'myspeed'); + expect(decoded.kind, ServiceKind.myspeed); + }); + test('WolDevice.fromJson fills broadcast and port defaults', () { final WolDevice device = WolDevice.fromJson({ 'id': 'w1', From 8598691b2060e1df4969bb2f616144e4e57c84a1 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 17:56:08 +0530 Subject: [PATCH 03/23] feat(service_myspeed): add 3-tab bottom navbar with status, history, and config --- .../src/screens/service_detail_screen.dart | 11 +- app/test/new_services_render_test.dart | 12 +- .../service_myspeed/lib/service_myspeed.dart | 8 + .../lib/src/models/myspeed_config.dart | 65 +++ .../lib/src/models/myspeed_status.dart | 72 +++ .../lib/src/models/myspeed_test.dart | 96 ++++ .../service_myspeed/lib/src/myspeed_api.dart | 77 ++++ .../service_myspeed/lib/src/myspeed_home.dart | 137 +++++- .../lib/src/myspeed_providers.dart | 67 +++ .../lib/src/tabs/myspeed_config_tab.dart | 250 ++++++++++ .../lib/src/tabs/myspeed_history_tab.dart | 293 ++++++++++++ .../lib/src/tabs/myspeed_status_tab.dart | 433 ++++++++++++++++++ services/service_myspeed/pubspec.yaml | 2 + .../test/myspeed_api_test.dart | 191 ++++++++ .../test/myspeed_home_test.dart | 135 +++++- 15 files changed, 1817 insertions(+), 32 deletions(-) create mode 100644 services/service_myspeed/lib/src/models/myspeed_config.dart create mode 100644 services/service_myspeed/lib/src/models/myspeed_status.dart create mode 100644 services/service_myspeed/lib/src/models/myspeed_test.dart create mode 100644 services/service_myspeed/lib/src/myspeed_api.dart create mode 100644 services/service_myspeed/lib/src/myspeed_providers.dart create mode 100644 services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart create mode 100644 services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart create mode 100644 services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart create mode 100644 services/service_myspeed/test/myspeed_api_test.dart diff --git a/app/lib/src/screens/service_detail_screen.dart b/app/lib/src/screens/service_detail_screen.dart index 6696b369..c3f9533c 100644 --- a/app/lib/src/screens/service_detail_screen.dart +++ b/app/lib/src/screens/service_detail_screen.dart @@ -124,6 +124,15 @@ class _ServiceDetailScreenState extends ConsumerState { ), ); } + if (instance.kind == ServiceKind.myspeed) { + return MySpeedHome( + instance: instance, + drawer: ServicesDrawer( + instances: ref.watch(activeInstancesProvider), + profile: ref.watch(activeProfileProvider), + ), + ); + } return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) { @@ -337,7 +346,7 @@ class _ServiceDetailScreenState extends ConsumerState { ServiceKind.gluetun => GluetunHome(instance: instance), ServiceKind.navidrome => const SizedBox.shrink(), ServiceKind.ombi => OmbiHome(instance: instance), - ServiceKind.myspeed => MySpeedHome(instance: instance), + ServiceKind.myspeed => const SizedBox.shrink(), }; } } diff --git a/app/test/new_services_render_test.dart b/app/test/new_services_render_test.dart index 9549cd29..4f0eba36 100644 --- a/app/test/new_services_render_test.dart +++ b/app/test/new_services_render_test.dart @@ -405,11 +405,17 @@ void main() { final Instance myspeed = _instance(ServiceKind.myspeed); await _pump( tester, - [], + [ + myspeedStatusProvider(myspeed).overrideWith( + (ref) async => const MySpeedStatus(isRunning: false), + ), + ], MySpeedHome(instance: myspeed), + pumps: 2, ); - expect(find.text('MySpeed'), findsOneWidget); - expect(find.text('MySpeed service integration ready.'), findsOneWidget); + expect(find.text('Execution Status'), findsOneWidget); + expect(find.text('Idle'), findsOneWidget); + expect(find.byType(NavigationBar), findsOneWidget); }); } diff --git a/services/service_myspeed/lib/service_myspeed.dart b/services/service_myspeed/lib/service_myspeed.dart index d0af5c1b..2dc10e77 100644 --- a/services/service_myspeed/lib/service_myspeed.dart +++ b/services/service_myspeed/lib/service_myspeed.dart @@ -1 +1,9 @@ +export 'src/models/myspeed_config.dart'; +export 'src/models/myspeed_status.dart'; +export 'src/models/myspeed_test.dart'; +export 'src/myspeed_api.dart'; export 'src/myspeed_home.dart'; +export 'src/myspeed_providers.dart'; +export 'src/tabs/myspeed_config_tab.dart'; +export 'src/tabs/myspeed_history_tab.dart'; +export 'src/tabs/myspeed_status_tab.dart'; diff --git a/services/service_myspeed/lib/src/models/myspeed_config.dart b/services/service_myspeed/lib/src/models/myspeed_config.dart new file mode 100644 index 00000000..cc649f01 --- /dev/null +++ b/services/service_myspeed/lib/src/models/myspeed_config.dart @@ -0,0 +1,65 @@ +/// Configuration info returned from MySpeed `/api/config`. +class MySpeedConfig { + const MySpeedConfig({ + required this.entries, + this.cron, + this.provider, + this.server, + this.raw, + }); + + /// All configuration properties as a key-value map. + final Map entries; + + /// Cron expression for automated speed tests, if defined. + final String? cron; + + /// Underlying speed test provider (e.g. 'ookla', 'librespeed', 'cloudflare'). + final String? provider; + + /// Server or host name if configured. + final String? server; + + /// Raw payload from API. + final dynamic raw; + + factory MySpeedConfig.fromResponse(dynamic data) { + final Map map = {}; + + if (data is Map) { + for (final MapEntry entry in data.entries) { + map[entry.key.toString()] = entry.value; + } + } else if (data is List) { + for (final dynamic item in data) { + if (item is Map) { + final dynamic key = item['key'] ?? item['name'] ?? item['id']; + final dynamic val = item['value'] ?? item['val']; + if (key != null) { + map[key.toString()] = val; + } + } + } + } + + final String? cron = map['cron']?.toString() ?? + map['schedule']?.toString() ?? + map['interval']?.toString(); + + final String? provider = map['provider']?.toString() ?? + map['speedtest_provider']?.toString() ?? + map['type']?.toString(); + + final String? server = map['server']?.toString() ?? + map['hostname']?.toString() ?? + map['name']?.toString(); + + return MySpeedConfig( + entries: map, + cron: cron, + provider: provider, + server: server, + raw: data, + ); + } +} diff --git a/services/service_myspeed/lib/src/models/myspeed_status.dart b/services/service_myspeed/lib/src/models/myspeed_status.dart new file mode 100644 index 00000000..c1dcc88f --- /dev/null +++ b/services/service_myspeed/lib/src/models/myspeed_status.dart @@ -0,0 +1,72 @@ +/// Status of the MySpeed speedtest execution. +class MySpeedStatus { + const MySpeedStatus({ + required this.isRunning, + this.message, + this.timestamp, + this.raw, + }); + + /// Whether a speedtest is currently running on the server. + final bool isRunning; + + /// Optional status message or description from the server. + final String? message; + + /// Timestamp when the status was checked or reported. + final DateTime? timestamp; + + /// Unmodified response payload from the endpoint for diagnostics. + final dynamic raw; + + /// Parses server response from `GET /api/speedtests/status`. + factory MySpeedStatus.fromResponse(dynamic data) { + final DateTime now = DateTime.now(); + if (data == null) { + return MySpeedStatus(isRunning: false, timestamp: now); + } + if (data is bool) { + return MySpeedStatus(isRunning: data, timestamp: now, raw: data); + } + if (data is Map) { + final dynamic runningVal = data['running'] ?? data['isRunning'] ?? data['active']; + bool running = false; + if (runningVal is bool) { + running = runningVal; + } else if (runningVal != null) { + running = runningVal.toString().toLowerCase() == 'true'; + } + + final String? statusStr = data['status']?.toString(); + if (!running && statusStr != null) { + final String lower = statusStr.toLowerCase(); + running = lower == 'running' || lower == 'testing' || lower == 'active' || lower == 'in_progress'; + } + + final String? message = data['message'] as String? ?? statusStr; + return MySpeedStatus( + isRunning: running, + message: message, + timestamp: now, + raw: data, + ); + } + if (data is String) { + final String trimmed = data.trim().toLowerCase(); + final bool running = trimmed == 'true' || + trimmed.contains('running') || + trimmed.contains('testing') || + trimmed.contains('active'); + return MySpeedStatus( + isRunning: running, + message: data, + timestamp: now, + raw: data, + ); + } + return MySpeedStatus(isRunning: false, timestamp: now, raw: data); + } + + @override + String toString() => 'MySpeedStatus(isRunning: $isRunning, message: $message)'; +} diff --git a/services/service_myspeed/lib/src/models/myspeed_test.dart b/services/service_myspeed/lib/src/models/myspeed_test.dart new file mode 100644 index 00000000..b1923791 --- /dev/null +++ b/services/service_myspeed/lib/src/models/myspeed_test.dart @@ -0,0 +1,96 @@ +/// An individual speedtest result record from MySpeed. +class MySpeedTest { + const MySpeedTest({ + required this.id, + required this.download, + required this.upload, + required this.ping, + this.jitter, + this.createdAt, + this.server, + this.raw, + }); + + final String id; + + /// Download speed in Mbps. + final double download; + + /// Upload speed in Mbps. + final double upload; + + /// Ping / latency in ms. + final double ping; + + /// Jitter in ms, if available. + final double? jitter; + + /// When the speed test occurred. + final DateTime? createdAt; + + /// Server or provider information. + final String? server; + + /// Raw payload from MySpeed API. + final Map? raw; + + factory MySpeedTest.fromJson(Map json) { + final dynamic idVal = json['id'] ?? json['_id'] ?? json['uuid'] ?? ''; + final double downloadVal = _parseDouble(json['download'] ?? json['down'] ?? json['downloadSpeed']); + final double uploadVal = _parseDouble(json['upload'] ?? json['up'] ?? json['uploadSpeed']); + final double pingVal = _parseDouble(json['ping'] ?? json['latency']); + final double? jitterVal = json['jitter'] != null ? _parseDouble(json['jitter']) : null; + + DateTime? date; + final dynamic rawDate = json['created_at'] ?? json['createdAt'] ?? json['time'] ?? json['date']; + if (rawDate is String) { + date = DateTime.tryParse(rawDate)?.toLocal(); + } else if (rawDate is int) { + date = DateTime.fromMillisecondsSinceEpoch(rawDate).toLocal(); + } + + String? serverName; + if (json['server'] is String) { + serverName = json['server'] as String; + } else if (json['server'] is Map) { + final Map sMap = json['server'] as Map; + serverName = (sMap['name'] ?? sMap['sponsor'] ?? sMap['location'])?.toString(); + } + + return MySpeedTest( + id: idVal.toString(), + download: downloadVal, + upload: uploadVal, + ping: pingVal, + jitter: jitterVal, + createdAt: date, + server: serverName, + raw: json, + ); + } + + static double _parseDouble(dynamic val) { + if (val == null) return 0.0; + if (val is num) return val.toDouble(); + return double.tryParse(val.toString()) ?? 0.0; + } + + String get formattedDownload => '${download.toStringAsFixed(1)} Mbps'; + String get formattedUpload => '${upload.toStringAsFixed(1)} Mbps'; + String get formattedPing => '${ping.toStringAsFixed(0)} ms'; + String? get formattedJitter => jitter != null ? '${jitter!.toStringAsFixed(0)} ms' : null; + + String get formattedTime { + if (createdAt == null) return ''; + final String hour = createdAt!.hour.toString().padLeft(2, '0'); + final String min = createdAt!.minute.toString().padLeft(2, '0'); + return '$hour:$min'; + } + + String get formattedDate { + if (createdAt == null) return ''; + final String month = createdAt!.month.toString().padLeft(2, '0'); + final String day = createdAt!.day.toString().padLeft(2, '0'); + return '$month/$day $formattedTime'; + } +} diff --git a/services/service_myspeed/lib/src/myspeed_api.dart b/services/service_myspeed/lib/src/myspeed_api.dart new file mode 100644 index 00000000..1ca3b354 --- /dev/null +++ b/services/service_myspeed/lib/src/myspeed_api.dart @@ -0,0 +1,77 @@ +import 'package:dio/dio.dart'; + +import 'models/myspeed_config.dart'; +import 'models/myspeed_status.dart'; +import 'models/myspeed_test.dart'; + +/// API client for interacting with MySpeed (`gnmyt/myspeed`). +class MySpeedApi { + const MySpeedApi(this._dio); + + final Dio _dio; + + /// Queries whether a speedtest is currently running via `GET /api/speedtests/status`. + Future getSpeedtestStatus() async { + final Response response = await _dio.get( + 'api/speedtests/status', + ); + return MySpeedStatus.fromResponse(response.data); + } + + /// Fetches historical speedtests via `GET /api/speedtests?hours=24`. + Future> getHistory({int hours = 24}) async { + final Response response = await _dio.get( + 'api/speedtests', + queryParameters: {'hours': hours}, + ); + + final dynamic data = response.data; + final List list; + if (data is List) { + list = data; + } else if (data is Map && data['data'] is List) { + list = data['data'] as List; + } else if (data is Map && data['results'] is List) { + list = data['results'] as List; + } else { + list = const []; + } + + final List tests = []; + for (final dynamic item in list) { + if (item is Map) { + tests.add(MySpeedTest.fromJson(item.cast())); + } + } + + // Sort descending by date (most recent first) + tests.sort((a, b) { + if (a.createdAt == null) return 1; + if (b.createdAt == null) return -1; + return b.createdAt!.compareTo(a.createdAt!); + }); + + return tests; + } + + /// Fetches MySpeed server configuration via `GET /api/config`. + Future getConfig() async { + final Response response = await _dio.get('api/config'); + return MySpeedConfig.fromResponse(response.data); + } + + /// Triggers a manual speedtest run on the server. + Future runSpeedtest() async { + try { + final Response response = await _dio.post('api/speedtests'); + return (response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300; + } on DioException catch (e) { + if (e.response?.statusCode == 404) { + // Fallback to alternative endpoint if available + final Response alt = await _dio.post('api/speedtests/run'); + return (alt.statusCode ?? 0) >= 200 && (alt.statusCode ?? 0) < 300; + } + rethrow; + } + } +} diff --git a/services/service_myspeed/lib/src/myspeed_home.dart b/services/service_myspeed/lib/src/myspeed_home.dart index 9e314e20..898bda9c 100644 --- a/services/service_myspeed/lib/src/myspeed_home.dart +++ b/services/service_myspeed/lib/src/myspeed_home.dart @@ -1,23 +1,138 @@ import 'package:core_models/core_models.dart'; +import 'package:core_router/core_router.dart'; import 'package:core_ui/core_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; -/// MySpeed service home screen. -class MySpeedHome extends ConsumerWidget { - const MySpeedHome({required this.instance, super.key}); +import 'myspeed_providers.dart'; +import 'tabs/myspeed_config_tab.dart'; +import 'tabs/myspeed_history_tab.dart'; +import 'tabs/myspeed_status_tab.dart'; + +/// MySpeed service home screen with bottom navigation bar (Status, History, Config). +class MySpeedHome extends ConsumerStatefulWidget { + const MySpeedHome({ + required this.instance, + this.drawer, + this.onEdit, + super.key, + }); final Instance instance; + final Widget? drawer; + final VoidCallback? onEdit; @override - Widget build(BuildContext context, WidgetRef ref) { - return Center( - child: Padding( - padding: Insets.page, - child: EmptyView( - icon: ServiceVisuals.icon(instance.kind), - title: 'MySpeed', - message: 'MySpeed service integration ready.', + ConsumerState createState() => _MySpeedHomeState(); +} + +class _MySpeedHomeState extends ConsumerState { + final GlobalKey _scaffoldKey = GlobalKey(); + + void _refreshActiveTab(int tabIndex) { + switch (tabIndex) { + case 0: + ref.invalidate(myspeedStatusProvider(widget.instance)); + ref.invalidate(myspeedHistoryProvider(widget.instance)); + case 1: + ref.invalidate(myspeedHistoryProvider(widget.instance)); + case 2: + ref.invalidate(myspeedConfigProvider(widget.instance)); + } + } + + @override + Widget build(BuildContext context) { + final int currentIndex = + ref.watch(myspeedActiveTabBarIndexProvider(widget.instance)); + + final List tabs = [ + MySpeedStatusTab(instance: widget.instance), + MySpeedHistoryTab(instance: widget.instance), + MySpeedConfigTab(instance: widget.instance), + ]; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) return; + final ScaffoldState? scaffold = _scaffoldKey.currentState; + if (scaffold?.isDrawerOpen ?? false) { + scaffold!.closeDrawer(); + return; + } + if (currentIndex != 0) { + ref.read(myspeedActiveTabBarIndexProvider(widget.instance).notifier).state = 0; + return; + } + GoRouter.of(context).go(AtriumRoutes.dashboard); + }, + child: Scaffold( + key: _scaffoldKey, + drawerEdgeDragWidth: widget.drawer != null ? 32 : null, + drawer: widget.drawer, + appBar: AppBar( + leading: Builder( + builder: (BuildContext ctx) => IconButton( + icon: const Icon(Icons.menu), + onPressed: () { + if (widget.drawer != null) { + _scaffoldKey.currentState?.openDrawer(); + } else { + Scaffold.of(ctx).openDrawer(); + } + }, + ), + ), + title: Row( + children: [ + Flexible( + child: Text( + widget.instance.name, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.instance.kind.isBeta) ...[ + const SizedBox(width: Insets.sm), + const BetaBadge(), + ], + ], + ), + actions: [ + IconButton( + tooltip: 'Refresh', + icon: const Icon(Icons.refresh), + onPressed: () => _refreshActiveTab(currentIndex), + ), + ], + ), + body: IndexedStack( + index: currentIndex, + children: tabs, + ), + bottomNavigationBar: NavigationBar( + selectedIndex: currentIndex, + onDestinationSelected: (int index) { + ref.read(myspeedActiveTabBarIndexProvider(widget.instance).notifier).state = index; + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.speed_outlined), + selectedIcon: Icon(Icons.speed), + label: 'Status', + ), + NavigationDestination( + icon: Icon(Icons.history_outlined), + selectedIcon: Icon(Icons.history), + label: 'History', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Config', + ), + ], ), ), ); diff --git a/services/service_myspeed/lib/src/myspeed_providers.dart b/services/service_myspeed/lib/src/myspeed_providers.dart new file mode 100644 index 00000000..36db5532 --- /dev/null +++ b/services/service_myspeed/lib/src/myspeed_providers.dart @@ -0,0 +1,67 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_networking/core_networking.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; + +import 'models/myspeed_config.dart'; +import 'models/myspeed_status.dart'; +import 'models/myspeed_test.dart'; +import 'myspeed_api.dart'; + +/// Active bottom navbar tab index for [instance] (0: Status, 1: History, 2: Config). +final myspeedActiveTabBarIndexProvider = + StateProvider.autoDispose.family((ref, instance) => 0); + +/// Provides a [MySpeedApi] client instance configured for the given [Instance]. +final myspeedApiProvider = + FutureProvider.autoDispose.family(( + Ref ref, + Instance instance, +) async { + final Dio dio = await ref.watch(instanceDioProvider(instance).future); + return MySpeedApi(dio); +}); + +/// Fetches the current speedtest execution status from `GET /api/speedtests/status`. +final myspeedStatusProvider = + FutureProvider.autoDispose.family(( + Ref ref, + Instance instance, +) async { + final MySpeedApi api = await ref.watch(myspeedApiProvider(instance).future); + return api.getSpeedtestStatus(); +}); + +/// Fetches the last 24 hours of speedtests from `GET /api/speedtests?hours=24`. +final myspeedHistoryProvider = + FutureProvider.autoDispose.family, Instance>(( + Ref ref, + Instance instance, +) async { + final MySpeedApi api = await ref.watch(myspeedApiProvider(instance).future); + return api.getHistory(); +}); + +/// Provides the single most recent speedtest result, or null if no results exist. +final myspeedLatestTestProvider = + Provider.autoDispose.family(( + Ref ref, + Instance instance, +) { + final AsyncValue> history = + ref.watch(myspeedHistoryProvider(instance)); + final List? list = history.asData?.value; + if (list == null || list.isEmpty) return null; + return list.first; +}); + +/// Fetches server configuration from `GET /api/config`. +final myspeedConfigProvider = + FutureProvider.autoDispose.family(( + Ref ref, + Instance instance, +) async { + final MySpeedApi api = await ref.watch(myspeedApiProvider(instance).future); + return api.getConfig(); +}); diff --git a/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart new file mode 100644 index 00000000..54dbddce --- /dev/null +++ b/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart @@ -0,0 +1,250 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/myspeed_config.dart'; +import '../myspeed_providers.dart'; + +/// Tab 2: Configuration information. +/// +/// Fetches `GET /api/config` and displays settings, cron schedule, and provider info. +class MySpeedConfigTab extends ConsumerStatefulWidget { + const MySpeedConfigTab({required this.instance, super.key}); + + final Instance instance; + + @override + ConsumerState createState() => _MySpeedConfigTabState(); +} + +class _MySpeedConfigTabState extends ConsumerState { + String _filterQuery = ''; + + @override + Widget build(BuildContext context) { + final AsyncValue configAsync = + ref.watch(myspeedConfigProvider(widget.instance)); + + return AsyncValueView( + value: configAsync, + onRetry: () => ref.invalidate(myspeedConfigProvider(widget.instance)), + data: (MySpeedConfig config) { + final Map filteredEntries = { + for (final MapEntry entry in config.entries.entries) + if (_filterQuery.isEmpty || + entry.key.toLowerCase().contains(_filterQuery.toLowerCase()) || + entry.value.toString().toLowerCase().contains(_filterQuery.toLowerCase())) + entry.key: entry.value, + }; + + return EasyRefresh( + onRefresh: () async { + ref.invalidate(myspeedConfigProvider(widget.instance)); + await ref.read(myspeedConfigProvider(widget.instance).future); + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: Insets.page, + children: [ + _buildOverviewCard(context, config), + const SizedBox(height: Insets.md), + _buildSearchBar(context), + const SizedBox(height: Insets.sm), + _buildEntriesCard(context, filteredEntries), + ], + ), + ); + }, + ); + } + + Widget _buildOverviewCard(BuildContext context, MySpeedConfig config) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final Color accent = ServiceVisuals.accent(widget.instance.kind); + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + ), + color: colors.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(Insets.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Configuration Overview', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Insets.md), + if (config.cron != null && config.cron!.isNotEmpty) ...[ + _overviewRow( + context, + icon: Icons.schedule_rounded, + label: 'Cron Schedule', + value: config.cron!, + accent: accent, + ), + const SizedBox(height: Insets.sm), + ], + if (config.provider != null && config.provider!.isNotEmpty) ...[ + _overviewRow( + context, + icon: Icons.hub_rounded, + label: 'Test Provider', + value: config.provider!, + accent: accent, + ), + const SizedBox(height: Insets.sm), + ], + if (config.server != null && config.server!.isNotEmpty) ...[ + _overviewRow( + context, + icon: Icons.dns_rounded, + label: 'Server / Node', + value: config.server!, + accent: accent, + ), + const SizedBox(height: Insets.sm), + ], + _overviewRow( + context, + icon: Icons.settings_ethernet_rounded, + label: 'Active Properties', + value: '${config.entries.length} keys loaded', + accent: accent, + ), + ], + ), + ), + ); + } + + Widget _overviewRow( + BuildContext context, { + required IconData icon, + required String label, + required String value, + required Color accent, + }) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Row( + children: [ + Icon(icon, size: 18, color: accent), + const SizedBox(width: Insets.sm), + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const Spacer(), + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } + + Widget _buildSearchBar(BuildContext context) { + return TextField( + decoration: InputDecoration( + hintText: 'Filter configuration keys...', + prefixIcon: const Icon(Icons.search_rounded, size: 20), + suffixIcon: _filterQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () => setState(() => _filterQuery = ''), + ) + : null, + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + onChanged: (String val) => setState(() => _filterQuery = val.trim()), + ); + } + + Widget _buildEntriesCard(BuildContext context, Map entries) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + if (entries.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: Insets.xl), + child: Center( + child: Text( + 'No matching configuration keys.', + style: theme.textTheme.bodyMedium?.copyWith(color: colors.outline), + ), + ), + ); + } + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.4)), + ), + color: colors.surfaceContainerLowest, + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: entries.length, + separatorBuilder: (_, __) => Divider( + height: 1, + color: colors.outlineVariant.withValues(alpha: 0.2), + ), + itemBuilder: (BuildContext context, int index) { + final String key = entries.keys.elementAt(index); + final dynamic value = entries.values.elementAt(index); + + return ListTile( + title: Text( + key, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + value.toString(), + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + trailing: IconButton( + icon: const Icon(Icons.copy_rounded, size: 18), + tooltip: 'Copy value', + onPressed: () { + Clipboard.setData(ClipboardData(text: value.toString())); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Copied "$key" to clipboard'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart new file mode 100644 index 00000000..44212dd1 --- /dev/null +++ b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart @@ -0,0 +1,293 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/myspeed_test.dart'; +import '../myspeed_providers.dart'; + +/// Tab 1: Historical speedtests in the last 24 hours. +/// +/// Fetches `GET /api/speedtests?hours=24`, displaying summary averages and a list +/// of speedtest cards. +class MySpeedHistoryTab extends ConsumerWidget { + const MySpeedHistoryTab({required this.instance, super.key}); + + final Instance instance; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue> historyAsync = + ref.watch(myspeedHistoryProvider(instance)); + + return AsyncValueView>( + value: historyAsync, + onRetry: () => ref.invalidate(myspeedHistoryProvider(instance)), + data: (List tests) { + return EasyRefresh( + onRefresh: () async { + ref.invalidate(myspeedHistoryProvider(instance)); + await ref.read(myspeedHistoryProvider(instance).future); + }, + child: tests.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: Insets.page, + children: const [ + SizedBox(height: 60), + EmptyView( + icon: Icons.history_rounded, + title: 'No Speedtests', + message: 'No speedtests recorded in the past 24 hours.', + ), + ], + ) + : ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: Insets.page, + itemCount: tests.length + 1, + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.only(bottom: Insets.md), + child: _buildSummaryCard(context, tests), + ); + } + final MySpeedTest test = tests[index - 1]; + return Padding( + padding: const EdgeInsets.only(bottom: Insets.sm), + child: _buildTestCard(context, test), + ); + }, + ), + ); + }, + ); + } + + Widget _buildSummaryCard(BuildContext context, List tests) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final Color accent = ServiceVisuals.accent(instance.kind); + + double sumDown = 0; + double sumUp = 0; + double sumPing = 0; + for (final MySpeedTest t in tests) { + sumDown += t.download; + sumUp += t.upload; + sumPing += t.ping; + } + final double avgDown = tests.isNotEmpty ? sumDown / tests.length : 0; + final double avgUp = tests.isNotEmpty ? sumUp / tests.length : 0; + final double avgPing = tests.isNotEmpty ? sumPing / tests.length : 0; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + ), + color: colors.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(Insets.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '24-Hour Summary', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${tests.length} tests', + style: theme.textTheme.labelSmall?.copyWith( + color: accent, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: Insets.md), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _summaryStat( + context, + label: 'Avg Down', + value: '${avgDown.toStringAsFixed(1)} Mbps', + icon: Icons.arrow_downward_rounded, + color: Colors.green, + ), + _summaryStat( + context, + label: 'Avg Up', + value: '${avgUp.toStringAsFixed(1)} Mbps', + icon: Icons.arrow_upward_rounded, + color: Colors.blue, + ), + _summaryStat( + context, + label: 'Avg Ping', + value: '${avgPing.toStringAsFixed(0)} ms', + icon: Icons.timer_outlined, + color: Colors.orange, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _summaryStat( + BuildContext context, { + required String label, + required String value, + required IconData icon, + required Color color, + }) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + value, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + Widget _buildTestCard(BuildContext context, MySpeedTest test) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.4)), + ), + color: colors.surfaceContainerLowest, + child: Padding( + padding: const EdgeInsets.all(Insets.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.access_time_rounded, size: 14, color: colors.onSurfaceVariant), + const SizedBox(width: 4), + Text( + test.formattedDate.isNotEmpty ? test.formattedDate : 'Recent Test', + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + ], + ), + if (test.server != null && test.server!.isNotEmpty) + Flexible( + child: Text( + test.server!, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: Insets.sm), + Divider(color: colors.outlineVariant.withValues(alpha: 0.2)), + const SizedBox(height: Insets.xs), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _metricPill( + context, + icon: Icons.arrow_downward_rounded, + label: 'Down', + value: test.formattedDownload, + color: Colors.green, + ), + _metricPill( + context, + icon: Icons.arrow_upward_rounded, + label: 'Up', + value: test.formattedUpload, + color: Colors.blue, + ), + _metricPill( + context, + icon: Icons.timer_outlined, + label: 'Ping', + value: test.formattedPing, + color: Colors.orange, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _metricPill( + BuildContext context, { + required IconData icon, + required String label, + required String value, + required Color color, + }) { + final ThemeData theme = Theme.of(context); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 4), + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} diff --git a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart new file mode 100644 index 00000000..1f67f97c --- /dev/null +++ b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart @@ -0,0 +1,433 @@ +import 'package:core_models/core_models.dart'; +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/myspeed_status.dart'; +import '../models/myspeed_test.dart'; +import '../myspeed_providers.dart'; + +/// Tab 0: Status & control tab. +/// +/// Displays whether a speedtest is currently running, triggers manual tests, +/// and presents the most recent speedtest result. +class MySpeedStatusTab extends ConsumerWidget { + const MySpeedStatusTab({required this.instance, super.key}); + + final Instance instance; + + Future _runSpeedtest(BuildContext context, WidgetRef ref) async { + final bool? confirmed = await showDialog( + context: context, + builder: (BuildContext ctx) => AlertDialog( + title: const Text('Run Speedtest'), + content: Text('Start a new speedtest on ${instance.name}?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: () => Navigator.of(ctx).pop(true), + icon: const Icon(Icons.bolt_rounded), + label: const Text('Start'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + try { + final api = await ref.read(myspeedApiProvider(instance).future); + await api.runSpeedtest(); + ref.invalidate(myspeedStatusProvider(instance)); + ref.invalidate(myspeedHistoryProvider(instance)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Speedtest triggered successfully')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to trigger speedtest: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue statusAsync = + ref.watch(myspeedStatusProvider(instance)); + final MySpeedTest? latestTest = ref.watch(myspeedLatestTestProvider(instance)); + final AsyncValue> historyAsync = + ref.watch(myspeedHistoryProvider(instance)); + + return AsyncValueView( + value: statusAsync, + onRetry: () { + ref.invalidate(myspeedStatusProvider(instance)); + ref.invalidate(myspeedHistoryProvider(instance)); + }, + data: (MySpeedStatus status) { + return EasyRefresh( + onRefresh: () async { + ref.invalidate(myspeedStatusProvider(instance)); + ref.invalidate(myspeedHistoryProvider(instance)); + await Future.wait(>[ + ref.read(myspeedStatusProvider(instance).future), + ref.read(myspeedHistoryProvider(instance).future), + ]); + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: Insets.page, + children: [ + _buildStatusCard(context, ref, status), + const SizedBox(height: Insets.md), + _buildRunCard(context, ref, status), + const SizedBox(height: Insets.md), + _buildLatestResultCard(context, latestTest, historyAsync), + ], + ), + ); + }, + ); + } + + Widget _buildStatusCard(BuildContext context, WidgetRef ref, MySpeedStatus status) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final Color accent = ServiceVisuals.accent(instance.kind); + final bool isRunning = status.isRunning; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: isRunning + ? accent.withValues(alpha: 0.6) + : colors.outlineVariant.withValues(alpha: 0.5), + width: isRunning ? 1.5 : 1.0, + ), + ), + color: isRunning + ? accent.withValues(alpha: 0.08) + : colors.surfaceContainerHighest.withValues(alpha: 0.3), + child: Padding( + padding: const EdgeInsets.all(Insets.lg), + child: Column( + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isRunning + ? accent.withValues(alpha: 0.2) + : colors.surfaceContainerHigh, + ), + child: Icon( + isRunning ? Icons.network_check_rounded : Icons.speed_rounded, + color: isRunning ? accent : colors.onSurfaceVariant, + size: 28, + ), + ), + const SizedBox(width: Insets.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Execution Status', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + isRunning ? 'Running' : 'Idle', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + color: isRunning ? accent : colors.onSurface, + ), + ), + const SizedBox(width: Insets.sm), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isRunning + ? accent.withValues(alpha: 0.2) + : colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isRunning ? accent : Colors.grey, + ), + ), + const SizedBox(width: 6), + Text( + isRunning ? 'ACTIVE' : 'IDLE', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + color: isRunning ? accent : colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: Insets.md), + Divider(color: colors.outlineVariant.withValues(alpha: 0.3)), + const SizedBox(height: Insets.sm), + Align( + alignment: Alignment.centerLeft, + child: Text( + isRunning + ? 'A speedtest is currently executing on your MySpeed instance.' + : 'No speedtest is currently running. Server is ready.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + if (status.message != null && status.message!.isNotEmpty) ...[ + const SizedBox(height: Insets.xs), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Message: ${status.message}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.outline, + ), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildRunCard(BuildContext context, WidgetRef ref, MySpeedStatus status) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final bool isRunning = status.isRunning; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + ), + color: colors.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(Insets.lg), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Manual Speedtest', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + isRunning + ? 'Speedtest is currently in progress...' + : 'Trigger an immediate test run', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + FilledButton.icon( + onPressed: isRunning ? null : () => _runSpeedtest(context, ref), + icon: isRunning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.bolt_rounded), + label: Text(isRunning ? 'Testing' : 'Run Test'), + ), + ], + ), + ), + ); + } + + Widget _buildLatestResultCard( + BuildContext context, + MySpeedTest? latest, + AsyncValue> historyAsync, + ) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + ), + color: colors.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(Insets.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Most Recent Result', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (latest?.createdAt != null) + Text( + latest!.formattedDate, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: Insets.lg), + if (latest != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _metricTile( + context, + label: 'DOWNLOAD', + value: latest.download.toStringAsFixed(1), + unit: 'Mbps', + icon: Icons.arrow_downward_rounded, + color: Colors.green, + ), + _metricTile( + context, + label: 'UPLOAD', + value: latest.upload.toStringAsFixed(1), + unit: 'Mbps', + icon: Icons.arrow_upward_rounded, + color: Colors.blue, + ), + _metricTile( + context, + label: 'PING', + value: latest.ping.toStringAsFixed(0), + unit: 'ms', + icon: Icons.timer_outlined, + color: Colors.orange, + ), + ], + ), + if (latest.server != null && latest.server!.isNotEmpty) ...[ + const SizedBox(height: Insets.md), + Divider(color: colors.outlineVariant.withValues(alpha: 0.3)), + const SizedBox(height: Insets.xs), + Text( + 'Server: ${latest.server}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ] else if (historyAsync.isLoading) ...[ + const Center( + child: Padding( + padding: EdgeInsets.all(Insets.lg), + child: CircularProgressIndicator(), + ), + ), + ] else ...[ + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: Insets.lg), + child: Text('No completed speedtests in the last 24 hours.'), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _metricTile( + BuildContext context, { + required String label, + required String value, + required String unit, + required IconData icon, + required Color color, + }) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colors.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + value, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colors.onSurface, + ), + ), + Text( + unit, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ); + } +} diff --git a/services/service_myspeed/pubspec.yaml b/services/service_myspeed/pubspec.yaml index b26dd3a8..816fe5bb 100644 --- a/services/service_myspeed/pubspec.yaml +++ b/services/service_myspeed/pubspec.yaml @@ -10,11 +10,13 @@ environment: dependencies: core_models: core_networking: + core_router: core_ui: dio: ^5.7.0 flutter: sdk: flutter flutter_riverpod: ^3.3.2 + go_router: ^17.3.0 dev_dependencies: flutter_lints: ^6.0.0 diff --git a/services/service_myspeed/test/myspeed_api_test.dart b/services/service_myspeed/test/myspeed_api_test.dart new file mode 100644 index 00000000..15e097c3 --- /dev/null +++ b/services/service_myspeed/test/myspeed_api_test.dart @@ -0,0 +1,191 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:service_myspeed/service_myspeed.dart'; + +void main() { + group('MySpeedStatus.fromResponse', () { + test('parses boolean responses', () { + expect(MySpeedStatus.fromResponse(true).isRunning, isTrue); + expect(MySpeedStatus.fromResponse(false).isRunning, isFalse); + }); + + test('parses map with running boolean', () { + expect(MySpeedStatus.fromResponse({'running': true}).isRunning, isTrue); + expect(MySpeedStatus.fromResponse({'running': false}).isRunning, isFalse); + }); + + test('parses map with status string', () { + final running = MySpeedStatus.fromResponse({ + 'status': 'running', + 'message': 'Testing download', + }); + expect(running.isRunning, isTrue); + expect(running.message, 'Testing download'); + + final idle = MySpeedStatus.fromResponse({ + 'status': 'idle', + }); + expect(idle.isRunning, isFalse); + }); + + test('parses string response', () { + expect(MySpeedStatus.fromResponse('running').isRunning, isTrue); + expect(MySpeedStatus.fromResponse('idle').isRunning, isFalse); + }); + + test('parses null safely', () { + expect(MySpeedStatus.fromResponse(null).isRunning, isFalse); + }); + }); + + group('MySpeedTest.fromJson', () { + test('parses speed test record correctly', () { + final test = MySpeedTest.fromJson({ + 'id': 101, + 'download': 250.5, + 'upload': 50.1, + 'ping': 14.2, + 'jitter': 2.1, + 'created_at': '2026-09-20T12:00:00.000Z', + 'server': 'Cloudflare', + }); + + expect(test.id, '101'); + expect(test.download, 250.5); + expect(test.upload, 50.1); + expect(test.ping, 14.2); + expect(test.jitter, 2.1); + expect(test.formattedDownload, '250.5 Mbps'); + expect(test.formattedUpload, '50.1 Mbps'); + expect(test.formattedPing, '14 ms'); + expect(test.server, 'Cloudflare'); + }); + }); + + group('MySpeedConfig.fromResponse', () { + test('parses map config', () { + final config = MySpeedConfig.fromResponse({ + 'cron': '*/30 * * * *', + 'provider': 'ookla', + 'server': 'node-1', + 'custom_key': 'custom_val', + }); + + expect(config.cron, '*/30 * * * *'); + expect(config.provider, 'ookla'); + expect(config.server, 'node-1'); + expect(config.entries['custom_key'], 'custom_val'); + }); + + test('parses list of key-values', () { + final config = MySpeedConfig.fromResponse([ + {'key': 'cron', 'value': '0 * * * *'}, + {'key': 'provider', 'value': 'librespeed'}, + ]); + + expect(config.cron, '0 * * * *'); + expect(config.provider, 'librespeed'); + }); + }); + + group('MySpeedApi', () { + test('getSpeedtestStatus calls api/speedtests/status', () async { + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter( + handler: (options) { + if (options.path.contains('status')) { + return ResponseBody.fromString( + '{"running": true, "message": "Ookla test in progress"}', + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + } + return ResponseBody.fromString('{}', 200); + }, + ); + + final api = MySpeedApi(dio); + final status = await api.getSpeedtestStatus(); + + expect(status.isRunning, isTrue); + expect(status.message, 'Ookla test in progress'); + }); + + test('getHistory calls api/speedtests with hours param', () async { + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter( + handler: (options) { + expect(options.queryParameters['hours'], 48); + return ResponseBody.fromString( + '[{"id": 1, "download": 100.0, "upload": 20.0, "ping": 10.0}]', + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + }, + ); + + final api = MySpeedApi(dio); + final history = await api.getHistory(hours: 48); + + expect(history.length, 1); + expect(history.first.download, 100.0); + }); + + test('getConfig calls api/config', () async { + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter( + handler: (options) { + return ResponseBody.fromString( + '{"cron": "0 * * * *", "provider": "ookla"}', + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + }, + ); + + final api = MySpeedApi(dio); + final config = await api.getConfig(); + + expect(config.cron, '0 * * * *'); + expect(config.provider, 'ookla'); + }); + + test('runSpeedtest sends POST', () async { + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter( + handler: (options) { + expect(options.method, 'POST'); + return ResponseBody.fromString('{"success": true}', 200); + }, + ); + + final api = MySpeedApi(dio); + final result = await api.runSpeedtest(); + expect(result, isTrue); + }); + }); +} + +class _FakeAdapter implements HttpClientAdapter { + _FakeAdapter({required this.handler}); + + final ResponseBody Function(RequestOptions options) handler; + + @override + Future fetch( + RequestOptions options, + Stream>? requestStream, + Future? cancelFuture, + ) async { + return handler(options); + } + + @override + void close({bool force = false}) {} +} diff --git a/services/service_myspeed/test/myspeed_home_test.dart b/services/service_myspeed/test/myspeed_home_test.dart index d8e9ac04..faea3bc0 100644 --- a/services/service_myspeed/test/myspeed_home_test.dart +++ b/services/service_myspeed/test/myspeed_home_test.dart @@ -1,34 +1,135 @@ import 'package:core_models/core_models.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:service_myspeed/service_myspeed.dart'; void main() { - testWidgets('MySpeedHome renders placeholder empty view', + const Instance instance = Instance( + id: 'test-myspeed', + name: 'MySpeed Test', + kind: ServiceKind.myspeed, + localUrl: 'http://localhost:5216', + externalUrl: '', + urlMode: UrlMode.auto, + auth: InstanceAuth.apiKey(apiKey: ''), + ); + + final List sampleTests = [ + MySpeedTest( + id: '1', + download: 320.5, + upload: 45.2, + ping: 12.0, + createdAt: DateTime.now(), + server: 'Local ISP', + ), + ]; + + const MySpeedConfig sampleConfig = MySpeedConfig( + entries: { + 'cron': '0 * * * *', + 'provider': 'ookla', + 'server_name': 'myspeed-node', + }, + cron: '0 * * * *', + provider: 'ookla', + ); + + List overridesForTab({ + bool isRunning = false, + int activeTab = 0, + }) { + return [ + myspeedActiveTabBarIndexProvider(instance).overrideWith((ref) => activeTab), + myspeedStatusProvider(instance).overrideWith( + (ref) async => MySpeedStatus(isRunning: isRunning), + ), + myspeedHistoryProvider(instance).overrideWith( + (ref) async => sampleTests, + ), + myspeedConfigProvider(instance).overrideWith( + (ref) async => sampleConfig, + ), + ]; + } + + testWidgets('MySpeedHome renders bottom NavigationBar with 3 destinations', (WidgetTester tester) async { - const Instance instance = Instance( - id: 'test-myspeed', - name: 'MySpeed Test', - kind: ServiceKind.myspeed, - localUrl: 'http://localhost:5216', - externalUrl: '', - urlMode: UrlMode.auto, - auth: const InstanceAuth.apiKey(apiKey: ''), + await tester.pumpWidget( + ProviderScope( + overrides: overridesForTab(), + child: const MaterialApp( + home: MySpeedHome(instance: instance), + ), + ), ); + await tester.pumpAndSettle(); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.text('Status'), findsOneWidget); + expect(find.text('History'), findsOneWidget); + expect(find.text('Config'), findsOneWidget); + }); + + testWidgets('Tab 0 renders status, run action, and latest result', + (WidgetTester tester) async { await tester.pumpWidget( - const ProviderScope( - child: MaterialApp( - home: Scaffold( - body: MySpeedHome(instance: instance), - ), + ProviderScope( + overrides: overridesForTab(), + child: const MaterialApp( + home: MySpeedHome(instance: instance), ), ), ); - expect(find.text('MySpeed'), findsOneWidget); - expect(find.text('MySpeed service integration ready.'), findsOneWidget); - expect(find.byIcon(Icons.network_check_outlined), findsOneWidget); + await tester.pumpAndSettle(); + + expect(find.text('Execution Status'), findsOneWidget); + expect(find.text('Idle'), findsOneWidget); + expect(find.text('Manual Speedtest'), findsOneWidget); + expect(find.text('Run Test'), findsOneWidget); + expect(find.text('Most Recent Result'), findsOneWidget); + expect(find.text('320.5'), findsOneWidget); + expect(find.text('45.2'), findsOneWidget); + }); + + testWidgets('Tab 1 renders 24-hour summary and test card', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: overridesForTab(activeTab: 1), + child: const MaterialApp( + home: MySpeedHome(instance: instance), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('24-Hour Summary'), findsOneWidget); + expect(find.text('1 tests'), findsOneWidget); + expect(find.text('320.5 Mbps'), findsNWidgets(2)); // summary + card + }); + + testWidgets('Tab 2 renders config overview and properties', + (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: overridesForTab(activeTab: 2), + child: const MaterialApp( + home: MySpeedHome(instance: instance), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Configuration Overview'), findsOneWidget); + expect(find.text('0 * * * *'), findsNWidgets(2)); + expect(find.text('ookla'), findsNWidgets(2)); + expect(find.text('server_name'), findsOneWidget); }); } From 89b35917ca62d90bd15ba2ebc58c7781d7974ecc Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 18:51:04 +0530 Subject: [PATCH 04/23] feat(app): add myspeed service icon asset --- app/assets/service_icons/myspeed.png | Bin 0 -> 31889 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/assets/service_icons/myspeed.png diff --git a/app/assets/service_icons/myspeed.png b/app/assets/service_icons/myspeed.png new file mode 100644 index 0000000000000000000000000000000000000000..af38e74b4595a58a65b29114a07b18724bbd27b4 GIT binary patch literal 31889 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 literal 0 HcmV?d00001 From 49a689ce07c9c338749a2fd893ddc7c1a8f3fae9 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 18:51:15 +0530 Subject: [PATCH 05/23] feat(service_myspeed): enhance MySpeedTest model with id, jitter, and timestamp formatting --- .../lib/src/models/myspeed_test.dart | 68 ++++++++++++++++--- services/service_myspeed/pubspec.yaml | 1 + 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/services/service_myspeed/lib/src/models/myspeed_test.dart b/services/service_myspeed/lib/src/models/myspeed_test.dart index b1923791..db2a7002 100644 --- a/services/service_myspeed/lib/src/models/myspeed_test.dart +++ b/services/service_myspeed/lib/src/models/myspeed_test.dart @@ -1,3 +1,5 @@ +import 'package:intl/intl.dart'; + /// An individual speedtest result record from MySpeed. class MySpeedTest { const MySpeedTest({ @@ -8,6 +10,7 @@ class MySpeedTest { this.jitter, this.createdAt, this.server, + this.duration, this.raw, }); @@ -31,6 +34,9 @@ class MySpeedTest { /// Server or provider information. final String? server; + /// Test execution duration in seconds or ms, if provided by API. + final int? duration; + /// Raw payload from MySpeed API. final Map? raw; @@ -42,21 +48,43 @@ class MySpeedTest { final double? jitterVal = json['jitter'] != null ? _parseDouble(json['jitter']) : null; DateTime? date; - final dynamic rawDate = json['created_at'] ?? json['createdAt'] ?? json['time'] ?? json['date']; + // MySpeed API returns "created" as ISO timestamp e.g. "2024-03-24T18:15:32.000Z" + // Note: Do NOT fallback to json['time'] because 'time' in MySpeed is the duration of test. + final dynamic rawDate = json['created'] ?? + json['createdAt'] ?? + json['created_at'] ?? + json['timestamp'] ?? + json['date']; + if (rawDate is String) { - date = DateTime.tryParse(rawDate)?.toLocal(); + final String trimmed = rawDate.trim(); + DateTime? parsed = DateTime.tryParse(trimmed); + if (parsed == null && trimmed.contains(' ')) { + parsed = DateTime.tryParse(trimmed.replaceFirst(' ', 'T')); + } + date = parsed?.toLocal(); } else if (rawDate is int) { - date = DateTime.fromMillisecondsSinceEpoch(rawDate).toLocal(); + if (rawDate < 10000000000) { + date = DateTime.fromMillisecondsSinceEpoch(rawDate * 1000).toLocal(); + } else { + date = DateTime.fromMillisecondsSinceEpoch(rawDate).toLocal(); + } } String? serverName; - if (json['server'] is String) { + if (json['serverName'] != null) { + serverName = json['serverName'].toString(); + } else if (json['serverHost'] != null) { + serverName = json['serverHost'].toString(); + } else if (json['server'] is String) { serverName = json['server'] as String; } else if (json['server'] is Map) { final Map sMap = json['server'] as Map; serverName = (sMap['name'] ?? sMap['sponsor'] ?? sMap['location'])?.toString(); } + final int? durVal = json['time'] is int ? json['time'] as int : null; + return MySpeedTest( id: idVal.toString(), download: downloadVal, @@ -65,6 +93,7 @@ class MySpeedTest { jitter: jitterVal, createdAt: date, server: serverName, + duration: durVal, raw: json, ); } @@ -80,17 +109,36 @@ class MySpeedTest { String get formattedPing => '${ping.toStringAsFixed(0)} ms'; String? get formattedJitter => jitter != null ? '${jitter!.toStringAsFixed(0)} ms' : null; + /// Formats time in local format (e.g., "5:30 PM" or "17:30"). String get formattedTime { if (createdAt == null) return ''; - final String hour = createdAt!.hour.toString().padLeft(2, '0'); - final String min = createdAt!.minute.toString().padLeft(2, '0'); - return '$hour:$min'; + return DateFormat.jm().format(createdAt!); } + /// Formats date and time nicely: + /// "Today, 5:30 PM", "Yesterday, 11:15 PM", or "Sep 20, 5:30 PM". String get formattedDate { if (createdAt == null) return ''; - final String month = createdAt!.month.toString().padLeft(2, '0'); - final String day = createdAt!.day.toString().padLeft(2, '0'); - return '$month/$day $formattedTime'; + final DateTime now = DateTime.now(); + final bool isToday = createdAt!.year == now.year && + createdAt!.month == now.month && + createdAt!.day == now.day; + if (isToday) { + return 'Today, ${DateFormat.jm().format(createdAt!)}'; + } + final DateTime yesterday = now.subtract(const Duration(days: 1)); + final bool isYesterday = createdAt!.year == yesterday.year && + createdAt!.month == yesterday.month && + createdAt!.day == yesterday.day; + if (isYesterday) { + return 'Yesterday, ${DateFormat.jm().format(createdAt!)}'; + } + return DateFormat.yMMMd().add_jm().format(createdAt!); + } + + /// Shorter date/time format for dense cards. + String get formattedShortDate { + if (createdAt == null) return ''; + return DateFormat.MMMd().add_jm().format(createdAt!); } } diff --git a/services/service_myspeed/pubspec.yaml b/services/service_myspeed/pubspec.yaml index 816fe5bb..4118810b 100644 --- a/services/service_myspeed/pubspec.yaml +++ b/services/service_myspeed/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: sdk: flutter flutter_riverpod: ^3.3.2 go_router: ^17.3.0 + intl: ^0.20.2 dev_dependencies: flutter_lints: ^6.0.0 From 048ef0444df692cc815b36586e468836ade3cb0a Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 18:51:19 +0530 Subject: [PATCH 06/23] feat(service_myspeed): add dedicated metric box and test card component --- .../service_myspeed/lib/service_myspeed.dart | 1 + .../lib/src/widgets/myspeed_test_card.dart | 248 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 services/service_myspeed/lib/src/widgets/myspeed_test_card.dart diff --git a/services/service_myspeed/lib/service_myspeed.dart b/services/service_myspeed/lib/service_myspeed.dart index 2dc10e77..edfbe9f8 100644 --- a/services/service_myspeed/lib/service_myspeed.dart +++ b/services/service_myspeed/lib/service_myspeed.dart @@ -7,3 +7,4 @@ export 'src/myspeed_providers.dart'; export 'src/tabs/myspeed_config_tab.dart'; export 'src/tabs/myspeed_history_tab.dart'; export 'src/tabs/myspeed_status_tab.dart'; +export 'src/widgets/myspeed_test_card.dart'; diff --git a/services/service_myspeed/lib/src/widgets/myspeed_test_card.dart b/services/service_myspeed/lib/src/widgets/myspeed_test_card.dart new file mode 100644 index 00000000..229aa127 --- /dev/null +++ b/services/service_myspeed/lib/src/widgets/myspeed_test_card.dart @@ -0,0 +1,248 @@ +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; + +import '../models/myspeed_test.dart'; + +/// Card displaying a single speedtest result with dedicated metric boxes, +/// dynamic colors, ID badge, and elevated highlights. +class MySpeedTestCard extends StatelessWidget { + const MySpeedTestCard({ + required this.test, + this.isHighlighted = false, + super.key, + }); + + final MySpeedTest test; + final bool isHighlighted; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + final String timeStr = test.formattedDate.isNotEmpty + ? test.formattedDate + : 'Recent Test'; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: isHighlighted + ? colors.primary.withValues(alpha: 0.6) + : colors.outlineVariant.withValues(alpha: 0.6), + width: isHighlighted ? 1.5 : 1.0, + ), + ), + color: colors.surfaceContainer, + child: Padding( + padding: const EdgeInsets.all(Insets.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + if (test.id.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.primaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: colors.primary.withValues(alpha: 0.3), + ), + ), + child: Text( + '#${test.id}', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ), + const SizedBox(width: Insets.xs), + ], + Icon( + Icons.access_time_rounded, + size: 14, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + timeStr, + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + ], + ), + if (test.server != null && test.server!.isNotEmpty) + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + test.server!, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + const SizedBox(height: Insets.md), + Row( + children: [ + Expanded( + child: MySpeedMetricBox( + icon: Icons.arrow_downward_rounded, + label: 'DOWN', + value: test.download.toStringAsFixed(1), + unit: 'Mbps', + iconColor: colors.primary, + boxColor: colors.primaryContainer.withValues(alpha: 0.25), + borderColor: colors.primary.withValues(alpha: 0.25), + ), + ), + const SizedBox(width: Insets.xs), + Expanded( + child: MySpeedMetricBox( + icon: Icons.arrow_upward_rounded, + label: 'UP', + value: test.upload.toStringAsFixed(1), + unit: 'Mbps', + iconColor: colors.tertiary, + boxColor: colors.tertiaryContainer.withValues(alpha: 0.25), + borderColor: colors.tertiary.withValues(alpha: 0.25), + ), + ), + const SizedBox(width: Insets.xs), + Expanded( + child: MySpeedMetricBox( + icon: Icons.timer_outlined, + label: 'PING', + value: test.ping.toStringAsFixed(0), + unit: 'ms', + iconColor: colors.secondary, + boxColor: colors.secondaryContainer.withValues(alpha: 0.25), + borderColor: colors.secondary.withValues(alpha: 0.25), + ), + ), + if (test.jitter != null) ...[ + const SizedBox(width: Insets.xs), + Expanded( + child: MySpeedMetricBox( + icon: Icons.graphic_eq_rounded, + label: 'JITTER', + value: test.jitter!.toStringAsFixed(0), + unit: 'ms', + iconColor: colors.onSurfaceVariant, + boxColor: colors.surfaceContainerHighest.withValues(alpha: 0.35), + borderColor: colors.outlineVariant.withValues(alpha: 0.3), + ), + ), + ], + ], + ), + ], + ), + ), + ); + } +} + +/// Dedicated box for a single speedtest metric with dynamic icon, color, and border. +class MySpeedMetricBox extends StatelessWidget { + const MySpeedMetricBox({ + required this.icon, + required this.label, + required this.value, + required this.unit, + required this.iconColor, + required this.boxColor, + required this.borderColor, + super.key, + }); + + final IconData icon; + final String label; + final String value; + final String unit; + final Color iconColor; + final Color boxColor; + final Color borderColor; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + decoration: BoxDecoration( + color: boxColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: iconColor), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.bold, + fontSize: 10, + letterSpacing: 0.5, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + value, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colors.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + unit, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + fontSize: 10, + ), + ), + ], + ), + ); + } +} From 29eb7752d2f95cf925692cc8d0ff7c19061bbe63 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 18:52:22 +0530 Subject: [PATCH 07/23] feat(service_myspeed): implement in-memory history caching and diff updates --- .../service_myspeed/lib/src/myspeed_api.dart | 98 ++++++++++++++----- .../service_myspeed/lib/src/myspeed_home.dart | 5 +- .../lib/src/myspeed_providers.dart | 90 +++++++++++++++-- 3 files changed, 160 insertions(+), 33 deletions(-) diff --git a/services/service_myspeed/lib/src/myspeed_api.dart b/services/service_myspeed/lib/src/myspeed_api.dart index 1ca3b354..917b9182 100644 --- a/services/service_myspeed/lib/src/myspeed_api.dart +++ b/services/service_myspeed/lib/src/myspeed_api.dart @@ -18,14 +18,83 @@ class MySpeedApi { return MySpeedStatus.fromResponse(response.data); } - /// Fetches historical speedtests via `GET /api/speedtests?hours=24`. - Future> getHistory({int hours = 24}) async { + /// Fetches speedtests via `GET /api/speedtests`. + /// + /// Optional [limit] specifies max records (e.g. 1000 for all tests, 10 for incremental diff). + /// Optional [afterId] specifies pagination cursor. + Future> getSpeedtests({int? limit, int? afterId}) async { + final Map params = {}; + if (limit != null) params['limit'] = limit; + if (afterId != null) params['afterId'] = afterId; + + final Response response = await _dio.get( + 'api/speedtests', + queryParameters: params.isNotEmpty ? params : null, + ); + + return _parseTests(response.data); + } + + /// Fetches speedtests from the past 24 hours via `GET /api/speedtests?hours=24`. + /// + /// Passes `hours=24` and `hour=24` query parameters to match various MySpeed backend + /// implementations and performs a client-side cutoff timestamp filter as a safeguard. + Future> get24HourSpeedtests() async { + final Response response = await _dio.get( + 'api/speedtests', + queryParameters: { + 'hours': 24, + 'hour': 24, + }, + ); + + final List tests = _parseTests(response.data); + final DateTime cutoff = DateTime.now().subtract(const Duration(hours: 24)); + return tests.where((MySpeedTest t) { + if (t.createdAt == null) return true; + return t.createdAt!.isAfter(cutoff); + }).toList(); + } + + /// Fetches historical speedtests via `GET /api/speedtests`. + /// + /// Optional [hours] param supported if backend implements it. + Future> getHistory({int? hours, int? hour}) async { + final Map params = {}; + if (hours != null) params['hours'] = hours; + if (hour != null) params['hour'] = hour; + final Response response = await _dio.get( 'api/speedtests', - queryParameters: {'hours': hours}, + queryParameters: params.isNotEmpty ? params : null, ); - final dynamic data = response.data; + return _parseTests(response.data); + } + + /// Fetches MySpeed server configuration via `GET /api/config`. + Future getConfig() async { + final Response response = await _dio.get('api/config'); + return MySpeedConfig.fromResponse(response.data); + } + + /// Triggers a manual speedtest run on the server. + /// + /// Calls `POST /api/speedtests/run` with fallback to `POST /api/speedtests`. + Future runSpeedtest() async { + try { + final Response response = await _dio.post('api/speedtests/run'); + return (response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300; + } on DioException catch (e) { + if (e.response?.statusCode == 404) { + final Response alt = await _dio.post('api/speedtests'); + return (alt.statusCode ?? 0) >= 200 && (alt.statusCode ?? 0) < 300; + } + rethrow; + } + } + + List _parseTests(dynamic data) { final List list; if (data is List) { list = data; @@ -53,25 +122,4 @@ class MySpeedApi { return tests; } - - /// Fetches MySpeed server configuration via `GET /api/config`. - Future getConfig() async { - final Response response = await _dio.get('api/config'); - return MySpeedConfig.fromResponse(response.data); - } - - /// Triggers a manual speedtest run on the server. - Future runSpeedtest() async { - try { - final Response response = await _dio.post('api/speedtests'); - return (response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300; - } on DioException catch (e) { - if (e.response?.statusCode == 404) { - // Fallback to alternative endpoint if available - final Response alt = await _dio.post('api/speedtests/run'); - return (alt.statusCode ?? 0) >= 200 && (alt.statusCode ?? 0) < 300; - } - rethrow; - } - } } diff --git a/services/service_myspeed/lib/src/myspeed_home.dart b/services/service_myspeed/lib/src/myspeed_home.dart index 898bda9c..14f9555b 100644 --- a/services/service_myspeed/lib/src/myspeed_home.dart +++ b/services/service_myspeed/lib/src/myspeed_home.dart @@ -34,9 +34,10 @@ class _MySpeedHomeState extends ConsumerState { switch (tabIndex) { case 0: ref.invalidate(myspeedStatusProvider(widget.instance)); - ref.invalidate(myspeedHistoryProvider(widget.instance)); + ref.invalidate(myspeed24HourTestsProvider(widget.instance)); + ref.read(myspeedHistoryProvider(widget.instance).notifier).fetchDiff(); case 1: - ref.invalidate(myspeedHistoryProvider(widget.instance)); + ref.read(myspeedHistoryProvider(widget.instance).notifier).reload(); case 2: ref.invalidate(myspeedConfigProvider(widget.instance)); } diff --git a/services/service_myspeed/lib/src/myspeed_providers.dart b/services/service_myspeed/lib/src/myspeed_providers.dart index 36db5532..76d7afeb 100644 --- a/services/service_myspeed/lib/src/myspeed_providers.dart +++ b/services/service_myspeed/lib/src/myspeed_providers.dart @@ -33,14 +33,84 @@ final myspeedStatusProvider = return api.getSpeedtestStatus(); }); -/// Fetches the last 24 hours of speedtests from `GET /api/speedtests?hours=24`. -final myspeedHistoryProvider = +/// Notifier that caches historical speedtests in memory across tab switches. +/// +/// Fetches all speedtests from `GET /api/speedtests` once and retains them. +/// Calling [fetchDiff] queries only the latest batch and prepends newly created +/// tests without reloading the entire dataset, avoiding UI lag. +class MySpeedHistoryNotifier extends AsyncNotifier> { + MySpeedHistoryNotifier(this.instance); + + final Instance instance; + + @override + Future> build() async { + final MySpeedApi api = await ref.watch(myspeedApiProvider(instance).future); + return api.getSpeedtests(limit: 1000); + } + + /// Incremental update: fetches only the most recent tests (limit: 10) + /// and prepends any new tests not present in local state. + Future fetchDiff() async { + final List? current = state.asData?.value; + if (current == null || current.isEmpty) { + ref.invalidateSelf(); + return; + } + + try { + final MySpeedApi api = await ref.read(myspeedApiProvider(instance).future); + final List latestBatch = await api.getSpeedtests(limit: 10); + + final Set existingIds = current.map((MySpeedTest t) => t.id).toSet(); + final List newItems = latestBatch + .where((MySpeedTest t) => !existingIds.contains(t.id)) + .toList(); + + if (newItems.isNotEmpty) { + final List merged = [ + ...newItems, + ...current, + ]; + merged.sort((a, b) { + if (a.createdAt == null) return 1; + if (b.createdAt == null) return -1; + return b.createdAt!.compareTo(a.createdAt!); + }); + state = AsyncData>(merged); + } + } catch (_) { + // Non-fatal background diff check + } + } + + /// Full reload of all speedtests (e.g. on manual pull-to-refresh). + Future reload() async { + state = const AsyncLoading>(); + state = await AsyncValue.guard(() async { + final MySpeedApi api = await ref.read(myspeedApiProvider(instance).future); + return api.getSpeedtests(limit: 1000); + }); + } +} + +/// Provider for historical speedtests. +/// +/// Kept alive in memory so that switching between tabs never triggers +/// repeated network requests. +final myspeedHistoryProvider = AsyncNotifierProvider.family< + MySpeedHistoryNotifier, + List, + Instance>(MySpeedHistoryNotifier.new); + +/// Fetches only the last 24 hours of speedtest results from `GET /api/speedtests?hours=24`. +final myspeed24HourTestsProvider = FutureProvider.autoDispose.family, Instance>(( Ref ref, Instance instance, ) async { final MySpeedApi api = await ref.watch(myspeedApiProvider(instance).future); - return api.getHistory(); + return api.get24HourSpeedtests(); }); /// Provides the single most recent speedtest result, or null if no results exist. @@ -49,11 +119,19 @@ final myspeedLatestTestProvider = Ref ref, Instance instance, ) { + final AsyncValue> recent24h = + ref.watch(myspeed24HourTestsProvider(instance)); + final List? list24h = recent24h.asData?.value; + if (list24h != null && list24h.isNotEmpty) { + return list24h.first; + } final AsyncValue> history = ref.watch(myspeedHistoryProvider(instance)); - final List? list = history.asData?.value; - if (list == null || list.isEmpty) return null; - return list.first; + final List? historyList = history.asData?.value; + if (historyList != null && historyList.isNotEmpty) { + return historyList.first; + } + return null; }); /// Fetches server configuration from `GET /api/config`. From c06cdb6c1afc91c377e3a0997d692628ca5f7af6 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 18:52:39 +0530 Subject: [PATCH 08/23] feat(service_myspeed): scope status tab to 24-hour speedtests and add dividers --- .../lib/src/tabs/myspeed_history_tab.dart | 260 ++++----------- .../lib/src/tabs/myspeed_status_tab.dart | 305 +++++++----------- 2 files changed, 173 insertions(+), 392 deletions(-) diff --git a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart index 44212dd1..9c0a7b52 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart @@ -5,11 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/myspeed_test.dart'; import '../myspeed_providers.dart'; +import '../widgets/myspeed_test_card.dart'; -/// Tab 1: Historical speedtests in the last 24 hours. -/// -/// Fetches `GET /api/speedtests?hours=24`, displaying summary averages and a list -/// of speedtest cards. class MySpeedHistoryTab extends ConsumerWidget { const MySpeedHistoryTab({required this.instance, super.key}); @@ -22,44 +19,37 @@ class MySpeedHistoryTab extends ConsumerWidget { return AsyncValueView>( value: historyAsync, - onRetry: () => ref.invalidate(myspeedHistoryProvider(instance)), + onRetry: () => ref.read(myspeedHistoryProvider(instance).notifier).reload(), data: (List tests) { return EasyRefresh( onRefresh: () async { - ref.invalidate(myspeedHistoryProvider(instance)); - await ref.read(myspeedHistoryProvider(instance).future); + await ref.read(myspeedHistoryProvider(instance).notifier).reload(); }, - child: tests.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: Insets.page, - children: const [ - SizedBox(height: 60), - EmptyView( - icon: Icons.history_rounded, - title: 'No Speedtests', - message: 'No speedtests recorded in the past 24 hours.', - ), + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: Insets.page, + itemCount: tests.length + 1, + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSummaryCard(context, tests), + const SizedBox(height: Insets.lg), + Divider(color: colors.outlineVariant.withValues(alpha: 0.4)), + const SizedBox(height: Insets.md), ], - ) - : ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: Insets.page, - itemCount: tests.length + 1, - itemBuilder: (BuildContext context, int index) { - if (index == 0) { - return Padding( - padding: const EdgeInsets.only(bottom: Insets.md), - child: _buildSummaryCard(context, tests), - ); - } - final MySpeedTest test = tests[index - 1]; - return Padding( - padding: const EdgeInsets.only(bottom: Insets.sm), - child: _buildTestCard(context, test), - ); - }, - ), + ); + } + final MySpeedTest test = tests[index - 1]; + return Padding( + padding: const EdgeInsets.only(bottom: Insets.sm), + child: MySpeedTestCard(test: test), + ); + }, + ), ); }, ); @@ -86,9 +76,9 @@ class MySpeedHistoryTab extends ConsumerWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.6)), ), - color: colors.surfaceContainerLow, + color: colors.surfaceContainer, child: Padding( padding: const EdgeInsets.all(Insets.lg), child: Column( @@ -98,7 +88,7 @@ class MySpeedHistoryTab extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '24-Hour Summary', + 'Historical Summary', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -119,145 +109,43 @@ class MySpeedHistoryTab extends ConsumerWidget { ), ], ), - const SizedBox(height: Insets.md), + const SizedBox(height: Insets.lg), Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _summaryStat( - context, - label: 'Avg Down', - value: '${avgDown.toStringAsFixed(1)} Mbps', - icon: Icons.arrow_downward_rounded, - color: Colors.green, - ), - _summaryStat( - context, - label: 'Avg Up', - value: '${avgUp.toStringAsFixed(1)} Mbps', - icon: Icons.arrow_upward_rounded, - color: Colors.blue, - ), - _summaryStat( - context, - label: 'Avg Ping', - value: '${avgPing.toStringAsFixed(0)} ms', - icon: Icons.timer_outlined, - color: Colors.orange, - ), - ], - ), - ], - ), - ), - ); - } - - Widget _summaryStat( - BuildContext context, { - required String label, - required String value, - required IconData icon, - required Color color, - }) { - final ThemeData theme = Theme.of(context); - final ColorScheme colors = theme.colorScheme; - - return Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: color), - const SizedBox(width: 4), - Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ], - ), - const SizedBox(height: 2), - Text( - value, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ); - } - - Widget _buildTestCard(BuildContext context, MySpeedTest test) { - final ThemeData theme = Theme.of(context); - final ColorScheme colors = theme.colorScheme; - - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.4)), - ), - color: colors.surfaceContainerLowest, - child: Padding( - padding: const EdgeInsets.all(Insets.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon(Icons.access_time_rounded, size: 14, color: colors.onSurfaceVariant), - const SizedBox(width: 4), - Text( - test.formattedDate.isNotEmpty ? test.formattedDate : 'Recent Test', - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.w600, - color: colors.onSurface, - ), - ), - ], - ), - if (test.server != null && test.server!.isNotEmpty) - Flexible( - child: Text( - test.server!, - style: theme.textTheme.labelSmall?.copyWith( - color: colors.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), + Expanded( + child: MySpeedMetricBox( + icon: Icons.arrow_downward_rounded, + label: 'AVG DOWN', + value: avgDown.toStringAsFixed(1), + unit: 'Mbps', + iconColor: colors.primary, + boxColor: colors.primaryContainer.withValues(alpha: 0.25), + borderColor: colors.primary.withValues(alpha: 0.25), ), - ], - ), - const SizedBox(height: Insets.sm), - Divider(color: colors.outlineVariant.withValues(alpha: 0.2)), - const SizedBox(height: Insets.xs), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _metricPill( - context, - icon: Icons.arrow_downward_rounded, - label: 'Down', - value: test.formattedDownload, - color: Colors.green, ), - _metricPill( - context, - icon: Icons.arrow_upward_rounded, - label: 'Up', - value: test.formattedUpload, - color: Colors.blue, + const SizedBox(width: Insets.xs), + Expanded( + child: MySpeedMetricBox( + icon: Icons.arrow_upward_rounded, + label: 'AVG UP', + value: avgUp.toStringAsFixed(1), + unit: 'Mbps', + iconColor: colors.tertiary, + boxColor: colors.tertiaryContainer.withValues(alpha: 0.25), + borderColor: colors.tertiary.withValues(alpha: 0.25), + ), ), - _metricPill( - context, - icon: Icons.timer_outlined, - label: 'Ping', - value: test.formattedPing, - color: Colors.orange, + const SizedBox(width: Insets.xs), + Expanded( + child: MySpeedMetricBox( + icon: Icons.timer_outlined, + label: 'AVG PING', + value: avgPing.toStringAsFixed(0), + unit: 'ms', + iconColor: colors.secondary, + boxColor: colors.secondaryContainer.withValues(alpha: 0.25), + borderColor: colors.secondary.withValues(alpha: 0.25), + ), ), ], ), @@ -266,28 +154,4 @@ class MySpeedHistoryTab extends ConsumerWidget { ), ); } - - Widget _metricPill( - BuildContext context, { - required IconData icon, - required String label, - required String value, - required Color color, - }) { - final ThemeData theme = Theme.of(context); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 4), - Text( - value, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ); - } } diff --git a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart index 1f67f97c..74e3cfe0 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart @@ -6,11 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/myspeed_status.dart'; import '../models/myspeed_test.dart'; import '../myspeed_providers.dart'; +import '../widgets/myspeed_test_card.dart'; -/// Tab 0: Status & control tab. -/// -/// Displays whether a speedtest is currently running, triggers manual tests, -/// and presents the most recent speedtest result. class MySpeedStatusTab extends ConsumerWidget { const MySpeedStatusTab({required this.instance, super.key}); @@ -27,10 +24,9 @@ class MySpeedStatusTab extends ConsumerWidget { onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Cancel'), ), - FilledButton.icon( + FilledButton( onPressed: () => Navigator.of(ctx).pop(true), - icon: const Icon(Icons.bolt_rounded), - label: const Text('Start'), + child: const Text('Start'), ), ], ), @@ -42,7 +38,7 @@ class MySpeedStatusTab extends ConsumerWidget { final api = await ref.read(myspeedApiProvider(instance).future); await api.runSpeedtest(); ref.invalidate(myspeedStatusProvider(instance)); - ref.invalidate(myspeedHistoryProvider(instance)); + ref.invalidate(myspeed24HourTestsProvider(instance)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Speedtest triggered successfully')), @@ -59,37 +55,43 @@ class MySpeedStatusTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; final AsyncValue statusAsync = ref.watch(myspeedStatusProvider(instance)); final MySpeedTest? latestTest = ref.watch(myspeedLatestTestProvider(instance)); - final AsyncValue> historyAsync = - ref.watch(myspeedHistoryProvider(instance)); + final AsyncValue> speedtests24hAsync = + ref.watch(myspeed24HourTestsProvider(instance)); return AsyncValueView( value: statusAsync, onRetry: () { ref.invalidate(myspeedStatusProvider(instance)); - ref.invalidate(myspeedHistoryProvider(instance)); + ref.invalidate(myspeed24HourTestsProvider(instance)); }, data: (MySpeedStatus status) { return EasyRefresh( onRefresh: () async { ref.invalidate(myspeedStatusProvider(instance)); - ref.invalidate(myspeedHistoryProvider(instance)); + ref.invalidate(myspeed24HourTestsProvider(instance)); await Future.wait(>[ ref.read(myspeedStatusProvider(instance).future), - ref.read(myspeedHistoryProvider(instance).future), + ref.read(myspeed24HourTestsProvider(instance).future), ]); }, child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: Insets.page, children: [ - _buildStatusCard(context, ref, status), + _buildStatusCard(context, status), const SizedBox(height: Insets.md), _buildRunCard(context, ref, status), const SizedBox(height: Insets.md), - _buildLatestResultCard(context, latestTest, historyAsync), + _buildLatestResultCard(context, latestTest), + const SizedBox(height: Insets.lg), + Divider(color: colors.outlineVariant.withValues(alpha: 0.4)), + const SizedBox(height: Insets.md), + _build24HourResultsSection(context, speedtests24hAsync), ], ), ); @@ -97,7 +99,7 @@ class MySpeedStatusTab extends ConsumerWidget { ); } - Widget _buildStatusCard(BuildContext context, WidgetRef ref, MySpeedStatus status) { + Widget _buildStatusCard(BuildContext context, MySpeedStatus status) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; final Color accent = ServiceVisuals.accent(instance.kind); @@ -111,12 +113,11 @@ class MySpeedStatusTab extends ConsumerWidget { color: isRunning ? accent.withValues(alpha: 0.6) : colors.outlineVariant.withValues(alpha: 0.5), - width: isRunning ? 1.5 : 1.0, ), ), color: isRunning ? accent.withValues(alpha: 0.08) - : colors.surfaceContainerHighest.withValues(alpha: 0.3), + : colors.surfaceContainer, child: Padding( padding: const EdgeInsets.all(Insets.lg), child: Column( @@ -149,80 +150,34 @@ class MySpeedStatusTab extends ConsumerWidget { ), ), const SizedBox(height: 2), - Row( - children: [ - Text( - isRunning ? 'Running' : 'Idle', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - color: isRunning ? accent : colors.onSurface, - ), - ), - const SizedBox(width: Insets.sm), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: isRunning - ? accent.withValues(alpha: 0.2) - : colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isRunning ? accent : Colors.grey, - ), - ), - const SizedBox(width: 6), - Text( - isRunning ? 'ACTIVE' : 'IDLE', - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - color: isRunning ? accent : colors.onSurfaceVariant, - ), - ), - ], - ), - ), - ], + Text( + isRunning ? 'Speedtest Running' : 'Idle', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: isRunning ? accent : colors.onSurface, + ), ), ], ), ), - ], - ), - const SizedBox(height: Insets.md), - Divider(color: colors.outlineVariant.withValues(alpha: 0.3)), - const SizedBox(height: Insets.sm), - Align( - alignment: Alignment.centerLeft, - child: Text( - isRunning - ? 'A speedtest is currently executing on your MySpeed instance.' - : 'No speedtest is currently running. Server is ready.', - style: theme.textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ), - if (status.message != null && status.message!.isNotEmpty) ...[ - const SizedBox(height: Insets.xs), - Align( - alignment: Alignment.centerLeft, - child: Text( - 'Message: ${status.message}', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.outline, + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isRunning + ? accent.withValues(alpha: 0.15) + : colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + isRunning ? 'ACTIVE' : 'IDLE', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: isRunning ? accent : colors.onSurfaceVariant, + ), ), ), - ), - ], + ], + ), ], ), ), @@ -238,9 +193,9 @@ class MySpeedStatusTab extends ConsumerWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.6)), ), - color: colors.surfaceContainerLow, + color: colors.surfaceContainer, child: Padding( padding: const EdgeInsets.all(Insets.lg), child: Row( @@ -257,9 +212,7 @@ class MySpeedStatusTab extends ConsumerWidget { ), const SizedBox(height: 2), Text( - isRunning - ? 'Speedtest is currently in progress...' - : 'Trigger an immediate test run', + 'Trigger an immediate test run', style: theme.textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), @@ -267,16 +220,9 @@ class MySpeedStatusTab extends ConsumerWidget { ], ), ), - FilledButton.icon( + FilledButton( onPressed: isRunning ? null : () => _runSpeedtest(context, ref), - icon: isRunning - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.bolt_rounded), - label: Text(isRunning ? 'Testing' : 'Run Test'), + child: const Text('Run Test'), ), ], ), @@ -284,11 +230,7 @@ class MySpeedStatusTab extends ConsumerWidget { ); } - Widget _buildLatestResultCard( - BuildContext context, - MySpeedTest? latest, - AsyncValue> historyAsync, - ) { + Widget _buildLatestResultCard(BuildContext context, MySpeedTest? latest) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; @@ -296,9 +238,9 @@ class MySpeedStatusTab extends ConsumerWidget { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.6)), ), - color: colors.surfaceContainerLow, + color: colors.surfaceContainer, child: Padding( padding: const EdgeInsets.all(Insets.lg), child: Column( @@ -313,119 +255,94 @@ class MySpeedStatusTab extends ConsumerWidget { fontWeight: FontWeight.w600, ), ), - if (latest?.createdAt != null) + if (latest != null) Text( - latest!.formattedDate, - style: theme.textTheme.labelSmall?.copyWith( - color: colors.onSurfaceVariant, + '#${latest.id}', + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colors.primary, ), ), ], ), - const SizedBox(height: Insets.lg), - if (latest != null) ...[ + const SizedBox(height: Insets.md), + if (latest != null) Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _metricTile( - context, - label: 'DOWNLOAD', - value: latest.download.toStringAsFixed(1), - unit: 'Mbps', - icon: Icons.arrow_downward_rounded, - color: Colors.green, + Expanded( + child: MySpeedMetricBox( + icon: Icons.arrow_downward_rounded, + label: 'DOWN', + value: latest.download.toStringAsFixed(1), + unit: 'Mbps', + iconColor: colors.primary, + boxColor: colors.primaryContainer.withValues(alpha: 0.25), + borderColor: colors.primary.withValues(alpha: 0.25), + ), ), - _metricTile( - context, - label: 'UPLOAD', - value: latest.upload.toStringAsFixed(1), - unit: 'Mbps', - icon: Icons.arrow_upward_rounded, - color: Colors.blue, + const SizedBox(width: Insets.xs), + Expanded( + child: MySpeedMetricBox( + icon: Icons.arrow_upward_rounded, + label: 'UP', + value: latest.upload.toStringAsFixed(1), + unit: 'Mbps', + iconColor: colors.tertiary, + boxColor: colors.tertiaryContainer.withValues(alpha: 0.25), + borderColor: colors.tertiary.withValues(alpha: 0.25), + ), ), - _metricTile( - context, - label: 'PING', - value: latest.ping.toStringAsFixed(0), - unit: 'ms', - icon: Icons.timer_outlined, - color: Colors.orange, + const SizedBox(width: Insets.xs), + Expanded( + child: MySpeedMetricBox( + icon: Icons.timer_outlined, + label: 'PING', + value: latest.ping.toStringAsFixed(0), + unit: 'ms', + iconColor: colors.secondary, + boxColor: colors.secondaryContainer.withValues(alpha: 0.25), + borderColor: colors.secondary.withValues(alpha: 0.25), + ), ), ], ), - if (latest.server != null && latest.server!.isNotEmpty) ...[ - const SizedBox(height: Insets.md), - Divider(color: colors.outlineVariant.withValues(alpha: 0.3)), - const SizedBox(height: Insets.xs), - Text( - 'Server: ${latest.server}', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ], - ] else if (historyAsync.isLoading) ...[ - const Center( - child: Padding( - padding: EdgeInsets.all(Insets.lg), - child: CircularProgressIndicator(), - ), - ), - ] else ...[ - const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: Insets.lg), - child: Text('No completed speedtests in the last 24 hours.'), - ), - ), - ], ], ), ), ); } - Widget _metricTile( - BuildContext context, { - required String label, - required String value, - required String unit, - required IconData icon, - required Color color, - }) { + Widget _build24HourResultsSection( + BuildContext context, + AsyncValue> speedtests24hAsync, + ) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: color), - const SizedBox(width: 4), - Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.bold, - color: colors.onSurfaceVariant, - letterSpacing: 0.5, - ), - ), - ], - ), - const SizedBox(height: 4), Text( - value, - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colors.onSurface, + '24-Hour Results', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, ), ), - Text( - unit, - style: theme.textTheme.labelSmall?.copyWith( - color: colors.onSurfaceVariant, - ), + const SizedBox(height: Insets.sm), + speedtests24hAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error: $e'), + data: (List tests) { + if (tests.isEmpty) { + return const Text('No results in last 24 hours'); + } + return Column( + children: tests.map((t) => Padding( + padding: const EdgeInsets.only(bottom: Insets.sm), + child: MySpeedTestCard(test: t), + )).toList(), + ); + }, ), ], ); From 9272bb1a5e0a9487cfde5f0b851133aee090ca97 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 18:52:55 +0530 Subject: [PATCH 09/23] feat(service_myspeed): add periodic polling and real-time running state transitions --- .../lib/src/tabs/myspeed_history_tab.dart | 41 +++++- .../lib/src/tabs/myspeed_status_tab.dart | 125 +++++++++++++----- 2 files changed, 128 insertions(+), 38 deletions(-) diff --git a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart index 9c0a7b52..d1b60e90 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:core_models/core_models.dart'; import 'package:core_ui/core_ui.dart'; import 'package:flutter/material.dart'; @@ -7,23 +9,50 @@ import '../models/myspeed_test.dart'; import '../myspeed_providers.dart'; import '../widgets/myspeed_test_card.dart'; -class MySpeedHistoryTab extends ConsumerWidget { +class MySpeedHistoryTab extends ConsumerStatefulWidget { const MySpeedHistoryTab({required this.instance, super.key}); final Instance instance; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _MySpeedHistoryTabState(); +} + +class _MySpeedHistoryTabState extends ConsumerState { + Timer? _pollingTimer; + + @override + void initState() { + super.initState(); + _pollingTimer = Timer.periodic(const Duration(seconds: 20), (_) => _poll()); + } + + Future _poll() async { + if (!mounted) return; + final int activeTab = ref.read(myspeedActiveTabBarIndexProvider(widget.instance)); + if (activeTab != 1) return; + + await ref.read(myspeedHistoryProvider(widget.instance).notifier).fetchDiff(); + } + + @override + void dispose() { + _pollingTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final AsyncValue> historyAsync = - ref.watch(myspeedHistoryProvider(instance)); + ref.watch(myspeedHistoryProvider(widget.instance)); return AsyncValueView>( value: historyAsync, - onRetry: () => ref.read(myspeedHistoryProvider(instance).notifier).reload(), + onRetry: () => ref.read(myspeedHistoryProvider(widget.instance).notifier).reload(), data: (List tests) { return EasyRefresh( onRefresh: () async { - await ref.read(myspeedHistoryProvider(instance).notifier).reload(); + await ref.read(myspeedHistoryProvider(widget.instance).notifier).reload(); }, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), @@ -58,7 +87,7 @@ class MySpeedHistoryTab extends ConsumerWidget { Widget _buildSummaryCard(BuildContext context, List tests) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; - final Color accent = ServiceVisuals.accent(instance.kind); + final Color accent = ServiceVisuals.accent(widget.instance.kind); double sumDown = 0; double sumUp = 0; diff --git a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart index 74e3cfe0..59cb4955 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:core_models/core_models.dart'; import 'package:core_ui/core_ui.dart'; import 'package:flutter/material.dart'; @@ -8,17 +10,67 @@ import '../models/myspeed_test.dart'; import '../myspeed_providers.dart'; import '../widgets/myspeed_test_card.dart'; -class MySpeedStatusTab extends ConsumerWidget { +class MySpeedStatusTab extends ConsumerStatefulWidget { const MySpeedStatusTab({required this.instance, super.key}); final Instance instance; - Future _runSpeedtest(BuildContext context, WidgetRef ref) async { + @override + ConsumerState createState() => _MySpeedStatusTabState(); +} + +class _MySpeedStatusTabState extends ConsumerState { + Timer? _pollingTimer; + bool _isLocallyRunning = false; + + @override + void initState() { + super.initState(); + _startPolling(); + } + + void _startPolling() { + _pollingTimer = Timer.periodic(const Duration(seconds: 5), (_) => _poll()); + } + + Future _poll() async { + if (!mounted) return; + final int activeTab = ref.read(myspeedActiveTabBarIndexProvider(widget.instance)); + final bool wasRunning = _isLocallyRunning || + (ref.read(myspeedStatusProvider(widget.instance)).value?.isRunning ?? false); + if (activeTab != 0 && !wasRunning) return; + + ref.invalidate(myspeedStatusProvider(widget.instance)); + ref.invalidate(myspeed24HourTestsProvider(widget.instance)); + + MySpeedStatus? newStatus; + try { + newStatus = await ref.read(myspeedStatusProvider(widget.instance).future); + } catch (_) { + newStatus = null; + } + + if (!mounted) return; + + if (_isLocallyRunning && (newStatus == null || !newStatus.isRunning)) { + setState(() => _isLocallyRunning = false); + ref.invalidate(myspeed24HourTestsProvider(widget.instance)); + ref.read(myspeedHistoryProvider(widget.instance).notifier).fetchDiff(); + } + } + + @override + void dispose() { + _pollingTimer?.cancel(); + super.dispose(); + } + + Future _runSpeedtest() async { final bool? confirmed = await showDialog( context: context, builder: (BuildContext ctx) => AlertDialog( title: const Text('Run Speedtest'), - content: Text('Start a new speedtest on ${instance.name}?'), + content: Text('Start a new speedtest on ${widget.instance.name}?'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), @@ -32,60 +84,69 @@ class MySpeedStatusTab extends ConsumerWidget { ), ); - if (confirmed != true || !context.mounted) return; + if (confirmed != true || !mounted) return; + + setState(() => _isLocallyRunning = true); try { - final api = await ref.read(myspeedApiProvider(instance).future); + final api = await ref.read(myspeedApiProvider(widget.instance).future); await api.runSpeedtest(); - ref.invalidate(myspeedStatusProvider(instance)); - ref.invalidate(myspeed24HourTestsProvider(instance)); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Speedtest triggered successfully')), - ); - } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Speedtest triggered successfully')), + ); + await _poll(); } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to trigger speedtest: $e')), - ); - } + if (!mounted) return; + setState(() => _isLocallyRunning = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to trigger speedtest: $e')), + ); + ref.invalidate(myspeedStatusProvider(widget.instance)); } } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; final AsyncValue statusAsync = - ref.watch(myspeedStatusProvider(instance)); - final MySpeedTest? latestTest = ref.watch(myspeedLatestTestProvider(instance)); + ref.watch(myspeedStatusProvider(widget.instance)); + final MySpeedTest? latestTest = ref.watch(myspeedLatestTestProvider(widget.instance)); final AsyncValue> speedtests24hAsync = - ref.watch(myspeed24HourTestsProvider(instance)); + ref.watch(myspeed24HourTestsProvider(widget.instance)); return AsyncValueView( value: statusAsync, onRetry: () { - ref.invalidate(myspeedStatusProvider(instance)); - ref.invalidate(myspeed24HourTestsProvider(instance)); + ref.invalidate(myspeedStatusProvider(widget.instance)); + ref.invalidate(myspeed24HourTestsProvider(widget.instance)); }, data: (MySpeedStatus status) { + final MySpeedStatus effectiveStatus = _isLocallyRunning + ? const MySpeedStatus( + isRunning: true, + message: 'Speedtest in progress...', + ) + : status; + return EasyRefresh( onRefresh: () async { - ref.invalidate(myspeedStatusProvider(instance)); - ref.invalidate(myspeed24HourTestsProvider(instance)); + ref.invalidate(myspeedStatusProvider(widget.instance)); + ref.invalidate(myspeed24HourTestsProvider(widget.instance)); await Future.wait(>[ - ref.read(myspeedStatusProvider(instance).future), - ref.read(myspeed24HourTestsProvider(instance).future), + ref.read(myspeedStatusProvider(widget.instance).future), + ref.read(myspeed24HourTestsProvider(widget.instance).future), + ref.read(myspeedHistoryProvider(widget.instance).notifier).fetchDiff(), ]); }, child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: Insets.page, children: [ - _buildStatusCard(context, status), + _buildStatusCard(context, effectiveStatus), const SizedBox(height: Insets.md), - _buildRunCard(context, ref, status), + _buildRunCard(context, effectiveStatus), const SizedBox(height: Insets.md), _buildLatestResultCard(context, latestTest), const SizedBox(height: Insets.lg), @@ -102,7 +163,7 @@ class MySpeedStatusTab extends ConsumerWidget { Widget _buildStatusCard(BuildContext context, MySpeedStatus status) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; - final Color accent = ServiceVisuals.accent(instance.kind); + final Color accent = ServiceVisuals.accent(widget.instance.kind); final bool isRunning = status.isRunning; return Card( @@ -184,7 +245,7 @@ class MySpeedStatusTab extends ConsumerWidget { ); } - Widget _buildRunCard(BuildContext context, WidgetRef ref, MySpeedStatus status) { + Widget _buildRunCard(BuildContext context, MySpeedStatus status) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; final bool isRunning = status.isRunning; @@ -221,7 +282,7 @@ class MySpeedStatusTab extends ConsumerWidget { ), ), FilledButton( - onPressed: isRunning ? null : () => _runSpeedtest(context, ref), + onPressed: isRunning ? null : _runSpeedtest, child: const Text('Run Test'), ), ], From 5750b8afe698daabd4d74c60d6866d8cc4d99be3 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 18:53:03 +0530 Subject: [PATCH 10/23] feat(core_models): graduate myspeed service integration out of beta --- packages/core_models/lib/src/service_kind.dart | 3 +-- packages/core_models/test/service_kind_test.dart | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core_models/lib/src/service_kind.dart b/packages/core_models/lib/src/service_kind.dart index ca396571..2ccdf6ec 100644 --- a/packages/core_models/lib/src/service_kind.dart +++ b/packages/core_models/lib/src/service_kind.dart @@ -107,8 +107,7 @@ extension ServiceKindX on ServiceKind { ServiceKind.unraid || ServiceKind.gluetun || ServiceKind.navidrome || - ServiceKind.ombi || - ServiceKind.myspeed => + ServiceKind.ombi => true, _ => false, }; diff --git a/packages/core_models/test/service_kind_test.dart b/packages/core_models/test/service_kind_test.dart index 13642198..7083e494 100644 --- a/packages/core_models/test/service_kind_test.dart +++ b/packages/core_models/test/service_kind_test.dart @@ -49,10 +49,10 @@ void main() { expect(ServiceKind.ombi.acceptsTorrents, isFalse); }); - test('MySpeed is registered as a beta no-auth analytics service', () { + test('MySpeed is registered as a no-auth analytics service', () { expect(ServiceKind.myspeed.displayName, 'MySpeed'); expect(ServiceKind.myspeed.tagline, 'Internet speed'); - expect(ServiceKind.myspeed.isBeta, isTrue); + expect(ServiceKind.myspeed.isBeta, isFalse); expect(ServiceKind.myspeed.defaultPort, 5216); expect(ServiceKind.myspeed.authStyle, AuthStyle.none); expect(ServiceKind.myspeed.role, ServiceRole.analytics); @@ -103,7 +103,7 @@ void main() { expect(ServiceKind.tracearr.isBeta, isFalse); expect(ServiceKind.unraid.isBeta, isTrue); expect(ServiceKind.navidrome.isBeta, isTrue); - expect(ServiceKind.myspeed.isBeta, isTrue); + expect(ServiceKind.myspeed.isBeta, isFalse); }); test('Navidrome is registered as userPass mediaServer service', () { From d57808c76fb8c9ed86b52dcac422f566e583b301 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 18:53:19 +0530 Subject: [PATCH 11/23] feat(service_myspeed): merge execution status and manual test into full-width card --- .../lib/src/tabs/myspeed_status_tab.dart | 122 ++++++++++-------- 1 file changed, 69 insertions(+), 53 deletions(-) diff --git a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart index 59cb4955..9b71c8df 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart @@ -146,8 +146,6 @@ class _MySpeedStatusTabState extends ConsumerState { children: [ _buildStatusCard(context, effectiveStatus), const SizedBox(height: Insets.md), - _buildRunCard(context, effectiveStatus), - const SizedBox(height: Insets.md), _buildLatestResultCard(context, latestTest), const SizedBox(height: Insets.lg), Divider(color: colors.outlineVariant.withValues(alpha: 0.4)), @@ -174,11 +172,12 @@ class _MySpeedStatusTabState extends ConsumerState { color: isRunning ? accent.withValues(alpha: 0.6) : colors.outlineVariant.withValues(alpha: 0.5), + width: isRunning ? 1.5 : 1.0, ), ), color: isRunning ? accent.withValues(alpha: 0.08) - : colors.surfaceContainer, + : colors.surfaceContainerHighest.withValues(alpha: 0.3), child: Padding( padding: const EdgeInsets.all(Insets.lg), child: Column( @@ -211,23 +210,46 @@ class _MySpeedStatusTabState extends ConsumerState { ), ), const SizedBox(height: 2), - Text( - isRunning ? 'Speedtest Running' : 'Idle', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - color: isRunning ? accent : colors.onSurface, - ), + Row( + children: [ + Text( + isRunning ? 'Speedtest Running' : 'Idle', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: isRunning ? accent : colors.onSurface, + ), + ), + if (isRunning) ...[ + const SizedBox(width: Insets.xs), + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: accent, + ), + ), + ], + ], ), ], ), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), decoration: BoxDecoration( color: isRunning ? accent.withValues(alpha: 0.15) : colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isRunning + ? accent.withValues(alpha: 0.5) + : colors.outlineVariant.withValues(alpha: 0.4), + ), ), child: Text( isRunning ? 'ACTIVE' : 'IDLE', @@ -239,51 +261,45 @@ class _MySpeedStatusTabState extends ConsumerState { ), ], ), - ], - ), - ), - ); - } - - Widget _buildRunCard(BuildContext context, MySpeedStatus status) { - final ThemeData theme = Theme.of(context); - final ColorScheme colors = theme.colorScheme; - final bool isRunning = status.isRunning; - - return Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.6)), - ), - color: colors.surfaceContainer, - child: Padding( - padding: const EdgeInsets.all(Insets.lg), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Manual Speedtest', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - 'Trigger an immediate test run', - style: theme.textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - ), - ], + const SizedBox(height: Insets.md), + Divider(color: colors.outlineVariant.withValues(alpha: 0.3)), + const SizedBox(height: Insets.sm), + Align( + alignment: Alignment.centerLeft, + child: Text( + isRunning + ? 'A speedtest is currently executing on your MySpeed instance.' + : 'No speedtest is currently running. Server is ready.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), ), ), - FilledButton( - onPressed: isRunning ? null : _runSpeedtest, - child: const Text('Run Test'), + if (status.message != null && status.message!.isNotEmpty) ...[ + const SizedBox(height: Insets.xs), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Message: ${status.message}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.outline, + ), + ), + ), + ], + const SizedBox(height: Insets.md), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: isRunning ? null : _runSpeedtest, + child: isRunning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Run Test'), + ), ), ], ), From c0bbe28aa899aa1bf117ddbadea49710fe0fa1c5 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 18:53:32 +0530 Subject: [PATCH 12/23] fix(service_myspeed): resolve status card layout overflow and enforce dynamic theme colors --- .../lib/src/tabs/myspeed_history_tab.dart | 8 +- .../lib/src/tabs/myspeed_status_tab.dart | 267 ++++++++++++++---- 2 files changed, 223 insertions(+), 52 deletions(-) diff --git a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart index d1b60e90..cfc9d447 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart @@ -87,7 +87,6 @@ class _MySpeedHistoryTabState extends ConsumerState { Widget _buildSummaryCard(BuildContext context, List tests) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; - final Color accent = ServiceVisuals.accent(widget.instance.kind); double sumDown = 0; double sumUp = 0; @@ -125,13 +124,16 @@ class _MySpeedHistoryTabState extends ConsumerState { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: accent.withValues(alpha: 0.15), + color: colors.primaryContainer.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(10), + border: Border.all( + color: colors.primary.withValues(alpha: 0.3), + ), ), child: Text( '${tests.length} tests', style: theme.textTheme.labelSmall?.copyWith( - color: accent, + color: colors.primary, fontWeight: FontWeight.bold, ), ), diff --git a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart index 9b71c8df..4e14cfe1 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart @@ -10,6 +10,14 @@ import '../models/myspeed_test.dart'; import '../myspeed_providers.dart'; import '../widgets/myspeed_test_card.dart'; +/// Tab 0: Status & control tab. +/// +/// Displays: +/// 1. Execution status (Running pulse / Idle pill). +/// 2. Manual speedtest trigger button (no flash icon). +/// 3. Most recent speedtest result with accurate time. +/// 4. 24-hour speedtest results with accurate times. +/// Polls information every 5 seconds. class MySpeedStatusTab extends ConsumerStatefulWidget { const MySpeedStatusTab({required this.instance, super.key}); @@ -50,12 +58,19 @@ class _MySpeedStatusTabState extends ConsumerState { newStatus = null; } - if (!mounted) return; + final bool isNowRunning = newStatus?.isRunning ?? false; - if (_isLocallyRunning && (newStatus == null || !newStatus.isRunning)) { - setState(() => _isLocallyRunning = false); - ref.invalidate(myspeed24HourTestsProvider(widget.instance)); - ref.read(myspeedHistoryProvider(widget.instance).notifier).fetchDiff(); + if (mounted) { + if (isNowRunning && _isLocallyRunning) { + setState(() => _isLocallyRunning = false); + } else if (wasRunning && !isNowRunning && _isLocallyRunning) { + setState(() => _isLocallyRunning = false); + } + } + + // If speedtest just transitioned from running to idle, update history diff + if (wasRunning && !isNowRunning) { + await ref.read(myspeedHistoryProvider(widget.instance).notifier).fetchDiff(); } } @@ -86,6 +101,7 @@ class _MySpeedStatusTabState extends ConsumerState { if (confirmed != true || !mounted) return; + // Immediately reflect running state in UI setState(() => _isLocallyRunning = true); try { @@ -121,6 +137,7 @@ class _MySpeedStatusTabState extends ConsumerState { onRetry: () { ref.invalidate(myspeedStatusProvider(widget.instance)); ref.invalidate(myspeed24HourTestsProvider(widget.instance)); + ref.invalidate(myspeedHistoryProvider(widget.instance)); }, data: (MySpeedStatus status) { final MySpeedStatus effectiveStatus = _isLocallyRunning @@ -146,7 +163,7 @@ class _MySpeedStatusTabState extends ConsumerState { children: [ _buildStatusCard(context, effectiveStatus), const SizedBox(height: Insets.md), - _buildLatestResultCard(context, latestTest), + _buildLatestResultCard(context, latestTest, speedtests24hAsync), const SizedBox(height: Insets.lg), Divider(color: colors.outlineVariant.withValues(alpha: 0.4)), const SizedBox(height: Insets.md), @@ -161,7 +178,6 @@ class _MySpeedStatusTabState extends ConsumerState { Widget _buildStatusCard(BuildContext context, MySpeedStatus status) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; - final Color accent = ServiceVisuals.accent(widget.instance.kind); final bool isRunning = status.isRunning; return Card( @@ -170,14 +186,12 @@ class _MySpeedStatusTabState extends ConsumerState { borderRadius: BorderRadius.circular(16), side: BorderSide( color: isRunning - ? accent.withValues(alpha: 0.6) + ? colors.primary.withValues(alpha: 0.5) : colors.outlineVariant.withValues(alpha: 0.5), width: isRunning ? 1.5 : 1.0, ), ), - color: isRunning - ? accent.withValues(alpha: 0.08) - : colors.surfaceContainerHighest.withValues(alpha: 0.3), + color: colors.surfaceContainer, child: Padding( padding: const EdgeInsets.all(Insets.lg), child: Column( @@ -189,12 +203,12 @@ class _MySpeedStatusTabState extends ConsumerState { decoration: BoxDecoration( shape: BoxShape.circle, color: isRunning - ? accent.withValues(alpha: 0.2) + ? colors.primaryContainer.withValues(alpha: 0.35) : colors.surfaceContainerHigh, ), child: Icon( isRunning ? Icons.network_check_rounded : Icons.speed_rounded, - color: isRunning ? accent : colors.onSurfaceVariant, + color: isRunning ? colors.primary : colors.onSurfaceVariant, size: 28, ), ), @@ -211,12 +225,16 @@ class _MySpeedStatusTabState extends ConsumerState { ), const SizedBox(height: 2), Row( + mainAxisSize: MainAxisSize.min, children: [ - Text( - isRunning ? 'Speedtest Running' : 'Idle', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - color: isRunning ? accent : colors.onSurface, + Flexible( + child: Text( + isRunning ? 'Speedtest Running' : 'Idle', + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: isRunning ? colors.primary : colors.onSurface, + ), ), ), if (isRunning) ...[ @@ -226,7 +244,7 @@ class _MySpeedStatusTabState extends ConsumerState { height: 14, child: CircularProgressIndicator( strokeWidth: 2, - color: accent, + color: colors.primary, ), ), ], @@ -242,12 +260,12 @@ class _MySpeedStatusTabState extends ConsumerState { ), decoration: BoxDecoration( color: isRunning - ? accent.withValues(alpha: 0.15) + ? colors.primaryContainer.withValues(alpha: 0.35) : colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(20), border: Border.all( color: isRunning - ? accent.withValues(alpha: 0.5) + ? colors.primary.withValues(alpha: 0.5) : colors.outlineVariant.withValues(alpha: 0.4), ), ), @@ -255,7 +273,7 @@ class _MySpeedStatusTabState extends ConsumerState { isRunning ? 'ACTIVE' : 'IDLE', style: theme.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.bold, - color: isRunning ? accent : colors.onSurfaceVariant, + color: isRunning ? colors.primary : colors.onSurfaceVariant, ), ), ), @@ -307,7 +325,11 @@ class _MySpeedStatusTabState extends ConsumerState { ); } - Widget _buildLatestResultCard(BuildContext context, MySpeedTest? latest) { + Widget _buildLatestResultCard( + BuildContext context, + MySpeedTest? latest, + AsyncValue> historyAsync, + ) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; @@ -326,24 +348,61 @@ class _MySpeedStatusTabState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Most Recent Result', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + Row( + children: [ + Text( + 'Most Recent Result', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (latest != null && latest.id.isNotEmpty) ...[ + const SizedBox(width: Insets.sm), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.primaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: colors.primary.withValues(alpha: 0.3), + ), + ), + child: Text( + '#${latest.id}', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colors.primary, + ), + ), + ), + ], + ], ), - if (latest != null) - Text( - '#${latest.id}', - style: theme.textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - color: colors.primary, + if (latest?.createdAt != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + latest!.formattedDate, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), ), ), ], ), - const SizedBox(height: Insets.md), - if (latest != null) + const SizedBox(height: Insets.lg), + if (latest != null) ...[ Row( children: [ Expanded( @@ -381,8 +440,48 @@ class _MySpeedStatusTabState extends ConsumerState { borderColor: colors.secondary.withValues(alpha: 0.25), ), ), + if (latest.jitter != null) ...[ + const SizedBox(width: Insets.xs), + Expanded( + child: MySpeedMetricBox( + icon: Icons.graphic_eq_rounded, + label: 'JITTER', + value: latest.jitter!.toStringAsFixed(0), + unit: 'ms', + iconColor: colors.onSurfaceVariant, + boxColor: colors.surfaceContainerHighest.withValues(alpha: 0.35), + borderColor: colors.outlineVariant.withValues(alpha: 0.3), + ), + ), + ], ], ), + if (latest.server != null && latest.server!.isNotEmpty) ...[ + const SizedBox(height: Insets.md), + Divider(color: colors.outlineVariant.withValues(alpha: 0.3)), + const SizedBox(height: Insets.xs), + Text( + 'Server: ${latest.server}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ] else if (historyAsync.isLoading) ...[ + const Center( + child: Padding( + padding: EdgeInsets.all(Insets.lg), + child: CircularProgressIndicator(), + ), + ), + ] else ...[ + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: Insets.lg), + child: Text('No completed speedtests in the last 24 hours.'), + ), + ), + ], ], ), ), @@ -391,7 +490,7 @@ class _MySpeedStatusTabState extends ConsumerState { Widget _build24HourResultsSection( BuildContext context, - AsyncValue> speedtests24hAsync, + AsyncValue> historyAsync, ) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; @@ -399,25 +498,95 @@ class _MySpeedStatusTabState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '24-Hour Results', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, + Padding( + padding: const EdgeInsets.symmetric(horizontal: Insets.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '24-Hour Results', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + historyAsync.maybeWhen( + data: (List tests) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: colors.primaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${tests.length} tests', + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + orElse: () => const SizedBox.shrink(), + ), + ], ), ), const SizedBox(height: Insets.sm), - speedtests24hAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Text('Error: $e'), + historyAsync.when( + loading: () => const Center( + child: Padding( + padding: EdgeInsets.all(Insets.xl), + child: CircularProgressIndicator(), + ), + ), + error: (Object error, _) => Card( + elevation: 0, + color: colors.errorContainer.withValues(alpha: 0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: colors.error.withValues(alpha: 0.3)), + ), + child: Padding( + padding: const EdgeInsets.all(Insets.md), + child: Text( + 'Error loading 24-hour results: $error', + style: theme.textTheme.bodySmall?.copyWith(color: colors.error), + ), + ), + ), data: (List tests) { if (tests.isEmpty) { - return const Text('No results in last 24 hours'); + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide( + color: colors.outlineVariant.withValues(alpha: 0.6), + ), + ), + color: colors.surfaceContainer, + child: Padding( + padding: const EdgeInsets.all(Insets.xl), + child: Center( + child: Text( + 'No speedtests recorded in the past 24 hours.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ), + ); } + return Column( - children: tests.map((t) => Padding( - padding: const EdgeInsets.only(bottom: Insets.sm), - child: MySpeedTestCard(test: t), - )).toList(), + children: tests.map((MySpeedTest test) { + return Padding( + padding: const EdgeInsets.only(bottom: Insets.sm), + child: MySpeedTestCard(test: test), + ); + }).toList(), ); }, ), From f1a7aaf7cd3a99653a474d0bfd3eadc78b5202df Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 18:53:43 +0530 Subject: [PATCH 13/23] feat(service_myspeed): add history search by id and integrate storage usage endpoint --- .../service_myspeed/lib/service_myspeed.dart | 1 + .../lib/src/models/myspeed_storage.dart | 72 +++++++ .../service_myspeed/lib/src/myspeed_api.dart | 29 +++ .../lib/src/myspeed_providers.dart | 11 ++ .../lib/src/tabs/myspeed_config_tab.dart | 163 +++++++++++++-- .../lib/src/tabs/myspeed_history_tab.dart | 185 +++++++++++++++++- .../test/myspeed_api_test.dart | 104 ++++++++++ .../test/myspeed_home_test.dart | 54 ++++- 8 files changed, 592 insertions(+), 27 deletions(-) create mode 100644 services/service_myspeed/lib/src/models/myspeed_storage.dart diff --git a/services/service_myspeed/lib/service_myspeed.dart b/services/service_myspeed/lib/service_myspeed.dart index edfbe9f8..c6f0c619 100644 --- a/services/service_myspeed/lib/service_myspeed.dart +++ b/services/service_myspeed/lib/service_myspeed.dart @@ -1,5 +1,6 @@ export 'src/models/myspeed_config.dart'; export 'src/models/myspeed_status.dart'; +export 'src/models/myspeed_storage.dart'; export 'src/models/myspeed_test.dart'; export 'src/myspeed_api.dart'; export 'src/myspeed_home.dart'; diff --git a/services/service_myspeed/lib/src/models/myspeed_storage.dart b/services/service_myspeed/lib/src/models/myspeed_storage.dart new file mode 100644 index 00000000..c5970058 --- /dev/null +++ b/services/service_myspeed/lib/src/models/myspeed_storage.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; + +/// Represents storage and database information from MySpeed (`GET /api/storage`). +@immutable +class MySpeedStorage { + const MySpeedStorage({ + this.size = 0, + this.testCount, + }); + + /// Size of the database/storage in bytes. + final num size; + + /// Total count of tests stored in the database. + final int? testCount; + + factory MySpeedStorage.fromJson(dynamic json) { + if (json is Map) { + final dynamic rawSize = json['size']; + final num sizeNum; + if (rawSize is num) { + sizeNum = rawSize; + } else if (rawSize is String) { + sizeNum = num.tryParse(rawSize) ?? 0; + } else { + sizeNum = 0; + } + + final dynamic rawCount = json['testCount']; + final int? countInt; + if (rawCount is int) { + countInt = rawCount; + } else if (rawCount is num) { + countInt = rawCount.toInt(); + } else if (rawCount is String) { + countInt = int.tryParse(rawCount); + } else { + countInt = null; + } + + return MySpeedStorage( + size: sizeNum, + testCount: countInt, + ); + } + return const MySpeedStorage(); + } + + /// Formatted storage size string (e.g., '1.2 MB', '500 KB', '0 B'). + String get formattedSize { + if (size <= 0) return '0 B'; + const List units = ['B', 'KB', 'MB', 'GB', 'TB']; + int unitIndex = 0; + double current = size.toDouble(); + while (current >= 1024 && unitIndex < units.length - 1) { + current /= 1024; + unitIndex++; + } + return '${current.toStringAsFixed(current >= 100 ? 0 : 1)} ${units[unitIndex]}'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MySpeedStorage && + runtimeType == other.runtimeType && + size == other.size && + testCount == other.testCount; + + @override + int get hashCode => Object.hash(size, testCount); +} diff --git a/services/service_myspeed/lib/src/myspeed_api.dart b/services/service_myspeed/lib/src/myspeed_api.dart index 917b9182..4a3b9eb5 100644 --- a/services/service_myspeed/lib/src/myspeed_api.dart +++ b/services/service_myspeed/lib/src/myspeed_api.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'models/myspeed_config.dart'; import 'models/myspeed_status.dart'; +import 'models/myspeed_storage.dart'; import 'models/myspeed_test.dart'; /// API client for interacting with MySpeed (`gnmyt/myspeed`). @@ -72,12 +73,40 @@ class MySpeedApi { return _parseTests(response.data); } + /// Fetches a single speedtest by its ID via `GET /api/speedtests/:id`. + Future getSpeedtestById(String id) async { + try { + final Response response = await _dio.get('api/speedtests/$id'); + final dynamic data = response.data; + if (data == null) return null; + if (data is Map) { + final Map map = data.cast(); + if (map.containsKey('data') && map['data'] is Map) { + return MySpeedTest.fromJson((map['data'] as Map).cast()); + } + return MySpeedTest.fromJson(map); + } + return null; + } on DioException catch (e) { + if (e.response?.statusCode == 404) { + return null; + } + rethrow; + } + } + /// Fetches MySpeed server configuration via `GET /api/config`. Future getConfig() async { final Response response = await _dio.get('api/config'); return MySpeedConfig.fromResponse(response.data); } + /// Fetches storage usage information via `GET /api/storage`. + Future getStorage() async { + final Response response = await _dio.get('api/storage'); + return MySpeedStorage.fromJson(response.data); + } + /// Triggers a manual speedtest run on the server. /// /// Calls `POST /api/speedtests/run` with fallback to `POST /api/speedtests`. diff --git a/services/service_myspeed/lib/src/myspeed_providers.dart b/services/service_myspeed/lib/src/myspeed_providers.dart index 76d7afeb..994daf16 100644 --- a/services/service_myspeed/lib/src/myspeed_providers.dart +++ b/services/service_myspeed/lib/src/myspeed_providers.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/legacy.dart'; import 'models/myspeed_config.dart'; import 'models/myspeed_status.dart'; +import 'models/myspeed_storage.dart'; import 'models/myspeed_test.dart'; import 'myspeed_api.dart'; @@ -143,3 +144,13 @@ final myspeedConfigProvider = final MySpeedApi api = await ref.watch(myspeedApiProvider(instance).future); return api.getConfig(); }); + +/// Fetches database and storage information from `GET /api/storage`. +final myspeedStorageProvider = + FutureProvider.autoDispose.family(( + Ref ref, + Instance instance, +) async { + final MySpeedApi api = await ref.watch(myspeedApiProvider(instance).future); + return api.getStorage(); +}); diff --git a/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart index 54dbddce..d0a4ebb3 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart @@ -5,11 +5,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/myspeed_config.dart'; +import '../models/myspeed_storage.dart'; import '../myspeed_providers.dart'; /// Tab 2: Configuration information. /// -/// Fetches `GET /api/config` and displays settings, cron schedule, and provider info. +/// Fetches `GET /api/config` and `GET /api/storage` to display settings, +/// cron schedule, test provider info, and storage / database statistics. class MySpeedConfigTab extends ConsumerStatefulWidget { const MySpeedConfigTab({required this.instance, super.key}); @@ -26,10 +28,15 @@ class _MySpeedConfigTabState extends ConsumerState { Widget build(BuildContext context) { final AsyncValue configAsync = ref.watch(myspeedConfigProvider(widget.instance)); + final AsyncValue storageAsync = + ref.watch(myspeedStorageProvider(widget.instance)); return AsyncValueView( value: configAsync, - onRetry: () => ref.invalidate(myspeedConfigProvider(widget.instance)), + onRetry: () { + ref.invalidate(myspeedConfigProvider(widget.instance)); + ref.invalidate(myspeedStorageProvider(widget.instance)); + }, data: (MySpeedConfig config) { final Map filteredEntries = { for (final MapEntry entry in config.entries.entries) @@ -42,7 +49,11 @@ class _MySpeedConfigTabState extends ConsumerState { return EasyRefresh( onRefresh: () async { ref.invalidate(myspeedConfigProvider(widget.instance)); - await ref.read(myspeedConfigProvider(widget.instance).future); + ref.invalidate(myspeedStorageProvider(widget.instance)); + await Future.wait(>[ + ref.read(myspeedConfigProvider(widget.instance).future), + ref.read(myspeedStorageProvider(widget.instance).future), + ]); }, child: ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -50,6 +61,8 @@ class _MySpeedConfigTabState extends ConsumerState { children: [ _buildOverviewCard(context, config), const SizedBox(height: Insets.md), + _buildStorageCard(context, storageAsync, config), + const SizedBox(height: Insets.md), _buildSearchBar(context), const SizedBox(height: Insets.sm), _buildEntriesCard(context, filteredEntries), @@ -63,15 +76,14 @@ class _MySpeedConfigTabState extends ConsumerState { Widget _buildOverviewCard(BuildContext context, MySpeedConfig config) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; - final Color accent = ServiceVisuals.accent(widget.instance.kind); return Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.5)), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.6)), ), - color: colors.surfaceContainerLow, + color: colors.surfaceContainer, child: Padding( padding: const EdgeInsets.all(Insets.lg), child: Column( @@ -90,7 +102,7 @@ class _MySpeedConfigTabState extends ConsumerState { icon: Icons.schedule_rounded, label: 'Cron Schedule', value: config.cron!, - accent: accent, + color: colors.primary, ), const SizedBox(height: Insets.sm), ], @@ -100,7 +112,7 @@ class _MySpeedConfigTabState extends ConsumerState { icon: Icons.hub_rounded, label: 'Test Provider', value: config.provider!, - accent: accent, + color: colors.tertiary, ), const SizedBox(height: Insets.sm), ], @@ -110,7 +122,7 @@ class _MySpeedConfigTabState extends ConsumerState { icon: Icons.dns_rounded, label: 'Server / Node', value: config.server!, - accent: accent, + color: colors.secondary, ), const SizedBox(height: Insets.sm), ], @@ -119,7 +131,111 @@ class _MySpeedConfigTabState extends ConsumerState { icon: Icons.settings_ethernet_rounded, label: 'Active Properties', value: '${config.entries.length} keys loaded', - accent: accent, + color: colors.primary, + ), + ], + ), + ), + ); + } + + Widget _buildStorageCard( + BuildContext context, + AsyncValue storageAsync, + MySpeedConfig config, + ) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.6)), + ), + color: colors.surfaceContainer, + child: Padding( + padding: const EdgeInsets.all(Insets.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Storage & Retention', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + storageAsync.maybeWhen( + data: (MySpeedStorage storage) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: colors.primaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.primary.withValues(alpha: 0.3)), + ), + child: Text( + storage.formattedSize, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + orElse: () => const SizedBox.shrink(), + ), + ], + ), + const SizedBox(height: Insets.md), + storageAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: Insets.sm), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + error: (error, _) => Text( + 'Could not load storage info', + style: theme.textTheme.bodySmall?.copyWith(color: colors.outline), + ), + data: (MySpeedStorage storage) { + final String? retention = config.entries['retentionDays']?.toString(); + return Column( + children: [ + _overviewRow( + context, + icon: Icons.storage_rounded, + label: 'Database Size', + value: storage.formattedSize, + color: colors.primary, + ), + const SizedBox(height: Insets.sm), + _overviewRow( + context, + icon: Icons.analytics_outlined, + label: 'Tests Stored', + value: '${storage.testCount ?? 0}', + color: colors.secondary, + ), + if (retention != null && retention.isNotEmpty) ...[ + const SizedBox(height: Insets.sm), + _overviewRow( + context, + icon: Icons.auto_delete_outlined, + label: 'Data Retention', + value: '$retention days', + color: colors.tertiary, + ), + ], + ], + ); + }, ), ], ), @@ -132,14 +248,14 @@ class _MySpeedConfigTabState extends ConsumerState { required IconData icon, required String label, required String value, - required Color accent, + required Color color, }) { final ThemeData theme = Theme.of(context); final ColorScheme colors = theme.colorScheme; return Row( children: [ - Icon(icon, size: 18, color: accent), + Icon(icon, size: 18, color: color), const SizedBox(width: Insets.sm), Text( label, @@ -159,6 +275,8 @@ class _MySpeedConfigTabState extends ConsumerState { } Widget _buildSearchBar(BuildContext context) { + final ColorScheme colors = Theme.of(context).colorScheme; + return TextField( decoration: InputDecoration( hintText: 'Filter configuration keys...', @@ -170,10 +288,19 @@ class _MySpeedConfigTabState extends ConsumerState { ) : null, filled: true, - fillColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - border: OutlineInputBorder( + fillColor: colors.surfaceContainer, + enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colors.outlineVariant.withValues(alpha: 0.6), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colors.primary, + width: 1.5, + ), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), @@ -201,16 +328,16 @@ class _MySpeedConfigTabState extends ConsumerState { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.4)), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.6)), ), - color: colors.surfaceContainerLowest, + color: colors.surfaceContainer, child: ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: entries.length, separatorBuilder: (_, __) => Divider( height: 1, - color: colors.outlineVariant.withValues(alpha: 0.2), + color: colors.outlineVariant.withValues(alpha: 0.3), ), itemBuilder: (BuildContext context, int index) { final String key = entries.keys.elementAt(index); diff --git a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart index cfc9d447..e2265a47 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart @@ -6,9 +6,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/myspeed_test.dart'; +import '../myspeed_api.dart'; import '../myspeed_providers.dart'; import '../widgets/myspeed_test_card.dart'; +/// Tab 1: Historical speedtests tab. +/// +/// Displays all speedtests from `GET /api/speedtests` with summary averages +/// and virtualized list of test cards. Cached in memory so switching tabs +/// never causes API lag or repeated fetches. Polls every 20 seconds. class MySpeedHistoryTab extends ConsumerStatefulWidget { const MySpeedHistoryTab({required this.instance, super.key}); @@ -20,6 +26,13 @@ class MySpeedHistoryTab extends ConsumerStatefulWidget { class _MySpeedHistoryTabState extends ConsumerState { Timer? _pollingTimer; + Timer? _searchDebounce; + final TextEditingController _searchController = TextEditingController(); + + String _searchQuery = ''; + bool _isSearching = false; + MySpeedTest? _searchResult; + String? _searchError; @override void initState() { @@ -35,12 +48,107 @@ class _MySpeedHistoryTabState extends ConsumerState { await ref.read(myspeedHistoryProvider(widget.instance).notifier).fetchDiff(); } + void _onSearchChanged(String val) { + _searchDebounce?.cancel(); + final String trimmed = val.trim(); + if (trimmed.isEmpty) { + setState(() { + _searchQuery = ''; + _isSearching = false; + _searchResult = null; + _searchError = null; + }); + return; + } + _searchDebounce = Timer( + const Duration(milliseconds: 400), + () => _searchById(trimmed), + ); + } + + Future _searchById(String query) async { + final String cleanId = query.startsWith('#') ? query.substring(1).trim() : query; + if (cleanId.isEmpty) { + setState(() { + _searchQuery = ''; + _isSearching = false; + _searchResult = null; + _searchError = null; + }); + return; + } + + setState(() { + _searchQuery = cleanId; + _isSearching = true; + _searchError = null; + }); + + try { + final MySpeedApi api = await ref.read(myspeedApiProvider(widget.instance).future); + final MySpeedTest? result = await api.getSpeedtestById(cleanId); + if (!mounted) return; + setState(() { + _searchResult = result; + _isSearching = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _searchError = e.toString(); + _isSearching = false; + }); + } + } + @override void dispose() { _pollingTimer?.cancel(); + _searchDebounce?.cancel(); + _searchController.dispose(); super.dispose(); } + Widget _buildSearchBar(BuildContext context) { + final ColorScheme colors = Theme.of(context).colorScheme; + + return TextField( + controller: _searchController, + keyboardType: TextInputType.text, + decoration: InputDecoration( + hintText: 'Filter or search by test ID (e.g. 10)...', + prefixIcon: const Icon(Icons.search_rounded, size: 20), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + ) + : null, + filled: true, + fillColor: colors.surfaceContainer, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colors.outlineVariant.withValues(alpha: 0.6), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colors.primary, + width: 1.5, + ), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + onChanged: _onSearchChanged, + onSubmitted: (String val) => _searchById(val.trim()), + ); + } + @override Widget build(BuildContext context) { final AsyncValue> historyAsync = @@ -50,6 +158,10 @@ class _MySpeedHistoryTabState extends ConsumerState { value: historyAsync, onRetry: () => ref.read(myspeedHistoryProvider(widget.instance).notifier).reload(), data: (List tests) { + final int itemCount = _searchQuery.isNotEmpty + ? 2 + : (tests.isEmpty ? 2 : tests.length + 1); + return EasyRefresh( onRefresh: () async { await ref.read(myspeedHistoryProvider(widget.instance).notifier).reload(); @@ -57,7 +169,7 @@ class _MySpeedHistoryTabState extends ConsumerState { child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), padding: Insets.page, - itemCount: tests.length + 1, + itemCount: itemCount, itemBuilder: (BuildContext context, int index) { if (index == 0) { final ThemeData theme = Theme.of(context); @@ -69,9 +181,80 @@ class _MySpeedHistoryTabState extends ConsumerState { const SizedBox(height: Insets.lg), Divider(color: colors.outlineVariant.withValues(alpha: 0.4)), const SizedBox(height: Insets.md), + _buildSearchBar(context), + const SizedBox(height: Insets.md), ], ); } + + if (_searchQuery.isNotEmpty) { + if (_isSearching) { + return const Padding( + padding: EdgeInsets.all(Insets.xl), + child: Center(child: CircularProgressIndicator()), + ); + } + if (_searchError != null) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + return Card( + elevation: 0, + color: colors.errorContainer.withValues(alpha: 0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: colors.error.withValues(alpha: 0.3)), + ), + child: Padding( + padding: const EdgeInsets.all(Insets.md), + child: Text( + 'Error fetching speedtest #$_searchQuery: $_searchError', + style: theme.textTheme.bodySmall?.copyWith(color: colors.error), + ), + ), + ); + } + if (_searchResult == null) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide( + color: colors.outlineVariant.withValues(alpha: 0.6), + ), + ), + color: colors.surfaceContainer, + child: Padding( + padding: const EdgeInsets.all(Insets.xl), + child: Center( + child: Text( + 'No speedtest found with ID #$_searchQuery', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ), + ); + } + return Padding( + padding: const EdgeInsets.only(bottom: Insets.sm), + child: MySpeedTestCard(test: _searchResult!), + ); + } + + if (tests.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: Insets.xl), + child: EmptyView( + icon: Icons.history_rounded, + title: 'No Speedtests', + message: 'No speedtests recorded on this instance.', + ), + ); + } + final MySpeedTest test = tests[index - 1]; return Padding( padding: const EdgeInsets.only(bottom: Insets.sm), diff --git a/services/service_myspeed/test/myspeed_api_test.dart b/services/service_myspeed/test/myspeed_api_test.dart index 15e097c3..c01a4512 100644 --- a/services/service_myspeed/test/myspeed_api_test.dart +++ b/services/service_myspeed/test/myspeed_api_test.dart @@ -60,6 +60,30 @@ void main() { expect(test.formattedPing, '14 ms'); expect(test.server, 'Cloudflare'); }); + + test('parses MySpeed API schema with created timestamp and serverName', () { + final test = MySpeedTest.fromJson({ + 'id': 42, + 'download': 512.8, + 'upload': 105.4, + 'ping': 8.2, + 'jitter': 1.5, + 'time': 18, + 'created': '2026-09-20T12:00:00.000Z', + 'serverName': 'London Datacenter', + }); + + expect(test.id, '42'); + expect(test.download, 512.8); + expect(test.upload, 105.4); + expect(test.ping, 8.2); + expect(test.jitter, 1.5); + expect(test.duration, 18); + expect(test.server, 'London Datacenter'); + expect(test.createdAt, isNotNull); + expect(test.createdAt!.isUtc, isFalse); + expect(test.formattedDate.isNotEmpty, isTrue); + }); }); group('MySpeedConfig.fromResponse', () { @@ -135,6 +159,29 @@ void main() { expect(history.first.download, 100.0); }); + test('get24HourSpeedtests calls api/speedtests with hours=24 and hour=24 params', () async { + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter( + handler: (options) { + expect(options.queryParameters['hours'], 24); + expect(options.queryParameters['hour'], 24); + return ResponseBody.fromString( + '[{"id": 2, "download": 250.0, "upload": 50.0, "ping": 8.0}]', + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + }, + ); + + final api = MySpeedApi(dio); + final results = await api.get24HourSpeedtests(); + + expect(results.length, 1); + expect(results.first.download, 250.0); + }); + test('getConfig calls api/config', () async { final dio = Dio(); dio.httpClientAdapter = _FakeAdapter( @@ -169,6 +216,63 @@ void main() { final result = await api.runSpeedtest(); expect(result, isTrue); }); + + test('getSpeedtestById returns test when found', () async { + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter( + handler: (options) { + expect(options.path, 'api/speedtests/42'); + return ResponseBody.fromString( + '{"id": 42, "download": 250.5, "upload": 50.0, "ping": 8.0}', + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + }, + ); + + final api = MySpeedApi(dio); + final test = await api.getSpeedtestById('42'); + expect(test, isNotNull); + expect(test!.id, '42'); + expect(test.download, 250.5); + }); + + test('getSpeedtestById returns null on 404', () async { + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter( + handler: (options) { + return ResponseBody.fromString('{"error": "Not Found"}', 404); + }, + ); + + final api = MySpeedApi(dio); + final test = await api.getSpeedtestById('999'); + expect(test, isNull); + }); + + test('getStorage calls api/storage and parses response', () async { + final dio = Dio(); + dio.httpClientAdapter = _FakeAdapter( + handler: (options) { + expect(options.path, 'api/storage'); + return ResponseBody.fromString( + '{"size": 10485760, "testCount": 150}', + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + }, + ); + + final api = MySpeedApi(dio); + final storage = await api.getStorage(); + expect(storage.size, 10485760); + expect(storage.testCount, 150); + expect(storage.formattedSize, '10.0 MB'); + }); }); } diff --git a/services/service_myspeed/test/myspeed_home_test.dart b/services/service_myspeed/test/myspeed_home_test.dart index faea3bc0..52d29dee 100644 --- a/services/service_myspeed/test/myspeed_home_test.dart +++ b/services/service_myspeed/test/myspeed_home_test.dart @@ -5,6 +5,21 @@ import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:service_myspeed/service_myspeed.dart'; +class _FakeHistoryNotifier extends MySpeedHistoryNotifier { + _FakeHistoryNotifier(super.instance, this.tests); + + final List tests; + + @override + Future> build() async => tests; + + @override + Future fetchDiff() async {} + + @override + Future reload() async {} +} + void main() { const Instance instance = Instance( id: 'test-myspeed', @@ -37,6 +52,11 @@ void main() { provider: 'ookla', ); + const MySpeedStorage sampleStorage = MySpeedStorage( + size: 1048576, + testCount: 42, + ); + List overridesForTab({ bool isRunning = false, int activeTab = 0, @@ -46,12 +66,18 @@ void main() { myspeedStatusProvider(instance).overrideWith( (ref) async => MySpeedStatus(isRunning: isRunning), ), - myspeedHistoryProvider(instance).overrideWith( + myspeed24HourTestsProvider(instance).overrideWith( (ref) async => sampleTests, ), + myspeedHistoryProvider(instance).overrideWith( + () => _FakeHistoryNotifier(instance, sampleTests), + ), myspeedConfigProvider(instance).overrideWith( (ref) async => sampleConfig, ), + myspeedStorageProvider(instance).overrideWith( + (ref) async => sampleStorage, + ), ]; } @@ -74,7 +100,7 @@ void main() { expect(find.text('Config'), findsOneWidget); }); - testWidgets('Tab 0 renders status, run action, and latest result', + testWidgets('Tab 0 renders status, run action without flash icon, latest result, and 24h results', (WidgetTester tester) async { await tester.pumpWidget( ProviderScope( @@ -89,11 +115,18 @@ void main() { expect(find.text('Execution Status'), findsOneWidget); expect(find.text('Idle'), findsOneWidget); - expect(find.text('Manual Speedtest'), findsOneWidget); expect(find.text('Run Test'), findsOneWidget); + expect(find.byIcon(Icons.bolt_rounded), findsNothing); + expect(find.byIcon(Icons.bolt), findsNothing); expect(find.text('Most Recent Result'), findsOneWidget); - expect(find.text('320.5'), findsOneWidget); - expect(find.text('45.2'), findsOneWidget); + expect(find.text('#1'), findsAtLeastNWidgets(1)); + + await tester.drag(find.byType(ListView), const Offset(0, -300)); + await tester.pumpAndSettle(); + + expect(find.text('24-Hour Results'), findsOneWidget); + expect(find.text('1 tests'), findsOneWidget); + expect(find.text('320.5'), findsAtLeastNWidgets(1)); // from dedicated metric box }); testWidgets('Tab 1 renders 24-hour summary and test card', @@ -109,12 +142,14 @@ void main() { await tester.pumpAndSettle(); - expect(find.text('24-Hour Summary'), findsOneWidget); + expect(find.text('Historical Summary'), findsOneWidget); expect(find.text('1 tests'), findsOneWidget); - expect(find.text('320.5 Mbps'), findsNWidgets(2)); // summary + card + expect(find.byType(TextField), findsOneWidget); + expect(find.text('#1'), findsOneWidget); + expect(find.text('320.5'), findsNWidgets(2)); // summary + card }); - testWidgets('Tab 2 renders config overview and properties', + testWidgets('Tab 2 renders config overview, storage info, and properties', (WidgetTester tester) async { await tester.pumpWidget( ProviderScope( @@ -128,6 +163,9 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Configuration Overview'), findsOneWidget); + expect(find.text('Storage & Retention'), findsOneWidget); + expect(find.text('1.0 MB'), findsNWidgets(2)); // header pill + detail row + expect(find.text('42'), findsOneWidget); expect(find.text('0 * * * *'), findsNWidgets(2)); expect(find.text('ookla'), findsNWidgets(2)); expect(find.text('server_name'), findsOneWidget); From 722ad9e5eae7c2dcf5fe336453668f7286a396d6 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 19:01:46 +0530 Subject: [PATCH 14/23] fix(service_myspeed): refine config tab layout and remove redundant status spinner --- .../lib/src/tabs/myspeed_config_tab.dart | 55 +++++++------------ .../lib/src/tabs/myspeed_status_tab.dart | 33 +++-------- .../test/myspeed_home_test.dart | 2 +- 3 files changed, 28 insertions(+), 62 deletions(-) diff --git a/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart index d0a4ebb3..fb119499 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart @@ -64,7 +64,7 @@ class _MySpeedConfigTabState extends ConsumerState { _buildStorageCard(context, storageAsync, config), const SizedBox(height: Insets.md), _buildSearchBar(context), - const SizedBox(height: Insets.sm), + const SizedBox(height: Insets.md), _buildEntriesCard(context, filteredEntries), ], ), @@ -159,34 +159,11 @@ class _MySpeedConfigTabState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Storage & Retention', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - storageAsync.maybeWhen( - data: (MySpeedStorage storage) => Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: colors.primaryContainer.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: colors.primary.withValues(alpha: 0.3)), - ), - child: Text( - storage.formattedSize, - style: theme.textTheme.labelSmall?.copyWith( - color: colors.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - orElse: () => const SizedBox.shrink(), - ), - ], + Text( + 'Storage & Retention', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: Insets.md), storageAsync.when( @@ -313,12 +290,20 @@ class _MySpeedConfigTabState extends ConsumerState { final ColorScheme colors = theme.colorScheme; if (entries.isEmpty) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: Insets.xl), - child: Center( - child: Text( - 'No matching configuration keys.', - style: theme.textTheme.bodyMedium?.copyWith(color: colors.outline), + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.6)), + ), + color: colors.surfaceContainer, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Insets.xl), + child: Center( + child: Text( + 'No matching configuration keys.', + style: theme.textTheme.bodyMedium?.copyWith(color: colors.outline), + ), ), ), ); diff --git a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart index 4e14cfe1..0551a9db 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart @@ -223,32 +223,13 @@ class _MySpeedStatusTabState extends ConsumerState { color: colors.onSurfaceVariant, ), ), - const SizedBox(height: 2), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - isRunning ? 'Speedtest Running' : 'Idle', - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - color: isRunning ? colors.primary : colors.onSurface, - ), - ), - ), - if (isRunning) ...[ - const SizedBox(width: Insets.xs), - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colors.primary, - ), - ), - ], - ], + Text( + isRunning ? 'Speedtest Running' : 'Idle', + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: isRunning ? colors.primary : colors.onSurface, + ), ), ], ), diff --git a/services/service_myspeed/test/myspeed_home_test.dart b/services/service_myspeed/test/myspeed_home_test.dart index 52d29dee..e9b9041d 100644 --- a/services/service_myspeed/test/myspeed_home_test.dart +++ b/services/service_myspeed/test/myspeed_home_test.dart @@ -164,7 +164,7 @@ void main() { expect(find.text('Configuration Overview'), findsOneWidget); expect(find.text('Storage & Retention'), findsOneWidget); - expect(find.text('1.0 MB'), findsNWidgets(2)); // header pill + detail row + expect(find.text('1.0 MB'), findsOneWidget); // detail row expect(find.text('42'), findsOneWidget); expect(find.text('0 * * * *'), findsNWidgets(2)); expect(find.text('ookla'), findsNWidgets(2)); From e94b5718b4b07eee6eadd43c96c3c3e1e351f7c8 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 19:04:40 +0530 Subject: [PATCH 15/23] style(service_myspeed): rename 24-hour results section header to Recent Results --- services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart | 2 +- services/service_myspeed/test/myspeed_home_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart index 0551a9db..c6b65cfa 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart @@ -485,7 +485,7 @@ class _MySpeedStatusTabState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '24-Hour Results', + 'Recent Results', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), diff --git a/services/service_myspeed/test/myspeed_home_test.dart b/services/service_myspeed/test/myspeed_home_test.dart index e9b9041d..5c2f0132 100644 --- a/services/service_myspeed/test/myspeed_home_test.dart +++ b/services/service_myspeed/test/myspeed_home_test.dart @@ -124,7 +124,7 @@ void main() { await tester.drag(find.byType(ListView), const Offset(0, -300)); await tester.pumpAndSettle(); - expect(find.text('24-Hour Results'), findsOneWidget); + expect(find.text('Recent Results'), findsOneWidget); expect(find.text('1 tests'), findsOneWidget); expect(find.text('320.5'), findsAtLeastNWidgets(1)); // from dedicated metric box }); From a1613d5f6a41245b902128c38c2c7b99474b7d9e Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 19:06:47 +0530 Subject: [PATCH 16/23] feat(service_myspeed): remove redundant reload action icon from appbar --- .../service_myspeed/lib/src/myspeed_home.dart | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/services/service_myspeed/lib/src/myspeed_home.dart b/services/service_myspeed/lib/src/myspeed_home.dart index 14f9555b..0086d2b6 100644 --- a/services/service_myspeed/lib/src/myspeed_home.dart +++ b/services/service_myspeed/lib/src/myspeed_home.dart @@ -30,19 +30,6 @@ class MySpeedHome extends ConsumerStatefulWidget { class _MySpeedHomeState extends ConsumerState { final GlobalKey _scaffoldKey = GlobalKey(); - void _refreshActiveTab(int tabIndex) { - switch (tabIndex) { - case 0: - ref.invalidate(myspeedStatusProvider(widget.instance)); - ref.invalidate(myspeed24HourTestsProvider(widget.instance)); - ref.read(myspeedHistoryProvider(widget.instance).notifier).fetchDiff(); - case 1: - ref.read(myspeedHistoryProvider(widget.instance).notifier).reload(); - case 2: - ref.invalidate(myspeedConfigProvider(widget.instance)); - } - } - @override Widget build(BuildContext context) { final int currentIndex = @@ -100,13 +87,6 @@ class _MySpeedHomeState extends ConsumerState { ], ], ), - actions: [ - IconButton( - tooltip: 'Refresh', - icon: const Icon(Icons.refresh), - onPressed: () => _refreshActiveTab(currentIndex), - ), - ], ), body: IndexedStack( index: currentIndex, From 4626b69cac9077743403f26cca78a3b03142cfa7 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 19:14:21 +0530 Subject: [PATCH 17/23] test(app): add unit test verifying bundled myspeed brand icon --- app/test/myspeed_brand_icon_test.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 app/test/myspeed_brand_icon_test.dart diff --git a/app/test/myspeed_brand_icon_test.dart b/app/test/myspeed_brand_icon_test.dart new file mode 100644 index 00000000..9e2f2cc0 --- /dev/null +++ b/app/test/myspeed_brand_icon_test.dart @@ -0,0 +1,18 @@ +import 'package:core_models/core_models.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// MySpeed has its own icon in the service picker. +/// +/// The picker loads `assets/service_icons/.png` and falls back to a +/// generic server icon when there is none. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('the icon the picker asks for is bundled', () async { + final ByteData icon = await rootBundle + .load('assets/service_icons/${ServiceKind.myspeed.name}.png'); + + expect(icon.lengthInBytes, greaterThan(0)); + }); +} From 0ac4f9522ec10db1f03d47ef66e95a5261333208 Mon Sep 17 00:00:00 2001 From: retransmit Date: Sun, 20 Sep 2026 21:05:09 +0530 Subject: [PATCH 18/23] fix(service_myspeed): ask the server for the whole day and the whole history GET api/speedtests answers with the last 24 hours and ten rows unless told otherwise, so the History tab was a day and the day's list was ten tests. The list call now takes hours, limit and start, the three parameters the server reads; hour and afterId did nothing. The run call posts to api/speedtests/run alone, which is the endpoint that exists. --- .../service_myspeed/lib/src/myspeed_api.dart | 85 +++++++------------ .../lib/src/myspeed_providers.dart | 14 ++- .../test/myspeed_api_test.dart | 30 ++++--- 3 files changed, 60 insertions(+), 69 deletions(-) diff --git a/services/service_myspeed/lib/src/myspeed_api.dart b/services/service_myspeed/lib/src/myspeed_api.dart index 4a3b9eb5..08c83654 100644 --- a/services/service_myspeed/lib/src/myspeed_api.dart +++ b/services/service_myspeed/lib/src/myspeed_api.dart @@ -19,51 +19,29 @@ class MySpeedApi { return MySpeedStatus.fromResponse(response.data); } - /// Fetches speedtests via `GET /api/speedtests`. - /// - /// Optional [limit] specifies max records (e.g. 1000 for all tests, 10 for incremental diff). - /// Optional [afterId] specifies pagination cursor. - Future> getSpeedtests({int? limit, int? afterId}) async { - final Map params = {}; - if (limit != null) params['limit'] = limit; - if (afterId != null) params['afterId'] = afterId; + /// How far back the History tab reads, in hours. The server keeps tests + /// for as long as its retention setting says, so this only has to be + /// larger than that. + static const int historyHours = 24 * 365 * 5; - final Response response = await _dio.get( - 'api/speedtests', - queryParameters: params.isNotEmpty ? params : null, - ); + /// The most rows one list call asks for. + static const int pageLimit = 1000; - return _parseTests(response.data); - } - - /// Fetches speedtests from the past 24 hours via `GET /api/speedtests?hours=24`. - /// - /// Passes `hours=24` and `hour=24` query parameters to match various MySpeed backend - /// implementations and performs a client-side cutoff timestamp filter as a safeguard. - Future> get24HourSpeedtests() async { - final Response response = await _dio.get( - 'api/speedtests', - queryParameters: { - 'hours': 24, - 'hour': 24, - }, - ); - - final List tests = _parseTests(response.data); - final DateTime cutoff = DateTime.now().subtract(const Duration(hours: 24)); - return tests.where((MySpeedTest t) { - if (t.createdAt == null) return true; - return t.createdAt!.isAfter(cutoff); - }).toList(); - } - - /// Fetches historical speedtests via `GET /api/speedtests`. + /// Fetches speedtests via `GET /api/speedtests`. /// - /// Optional [hours] param supported if backend implements it. - Future> getHistory({int? hours, int? hour}) async { - final Map params = {}; - if (hours != null) params['hours'] = hours; - if (hour != null) params['hour'] = hour; + /// Left alone, the server answers with the last 24 hours and at most 10 + /// rows. [hours] widens the window, [limit] raises the count, and [start] + /// pages backwards: the rows before that test id. + Future> getSpeedtests({ + int? hours, + int? limit, + int? start, + }) async { + final Map params = { + if (hours != null) 'hours': hours, + if (limit != null) 'limit': limit, + if (start != null) 'start': start, + }; final Response response = await _dio.get( 'api/speedtests', @@ -73,6 +51,11 @@ class MySpeedApi { return _parseTests(response.data); } + /// The last 24 hours, whole: the server's default window with the row + /// cap lifted. + Future> get24HourSpeedtests() => + getSpeedtests(hours: 24, limit: pageLimit); + /// Fetches a single speedtest by its ID via `GET /api/speedtests/:id`. Future getSpeedtestById(String id) async { try { @@ -107,20 +90,10 @@ class MySpeedApi { return MySpeedStorage.fromJson(response.data); } - /// Triggers a manual speedtest run on the server. - /// - /// Calls `POST /api/speedtests/run` with fallback to `POST /api/speedtests`. - Future runSpeedtest() async { - try { - final Response response = await _dio.post('api/speedtests/run'); - return (response.statusCode ?? 0) >= 200 && (response.statusCode ?? 0) < 300; - } on DioException catch (e) { - if (e.response?.statusCode == 404) { - final Response alt = await _dio.post('api/speedtests'); - return (alt.statusCode ?? 0) >= 200 && (alt.statusCode ?? 0) < 300; - } - rethrow; - } + /// Starts a speedtest via `POST /api/speedtests/run`. The server answers + /// before the test ends; `getSpeedtestStatus` says when it has. + Future runSpeedtest() async { + await _dio.post('api/speedtests/run'); } List _parseTests(dynamic data) { diff --git a/services/service_myspeed/lib/src/myspeed_providers.dart b/services/service_myspeed/lib/src/myspeed_providers.dart index 994daf16..903df792 100644 --- a/services/service_myspeed/lib/src/myspeed_providers.dart +++ b/services/service_myspeed/lib/src/myspeed_providers.dart @@ -36,7 +36,9 @@ final myspeedStatusProvider = /// Notifier that caches historical speedtests in memory across tab switches. /// -/// Fetches all speedtests from `GET /api/speedtests` once and retains them. +/// Reads the whole history from `GET /api/speedtests` once (the server's +/// default is one day and ten rows, so the window and the cap are spelled +/// out) and retains it. /// Calling [fetchDiff] queries only the latest batch and prepends newly created /// tests without reloading the entire dataset, avoiding UI lag. class MySpeedHistoryNotifier extends AsyncNotifier> { @@ -47,7 +49,10 @@ class MySpeedHistoryNotifier extends AsyncNotifier> { @override Future> build() async { final MySpeedApi api = await ref.watch(myspeedApiProvider(instance).future); - return api.getSpeedtests(limit: 1000); + return api.getSpeedtests( + hours: MySpeedApi.historyHours, + limit: MySpeedApi.pageLimit, + ); } /// Incremental update: fetches only the most recent tests (limit: 10) @@ -90,7 +95,10 @@ class MySpeedHistoryNotifier extends AsyncNotifier> { state = const AsyncLoading>(); state = await AsyncValue.guard(() async { final MySpeedApi api = await ref.read(myspeedApiProvider(instance).future); - return api.getSpeedtests(limit: 1000); + return api.getSpeedtests( + hours: MySpeedApi.historyHours, + limit: MySpeedApi.pageLimit, + ); }); } } diff --git a/services/service_myspeed/test/myspeed_api_test.dart b/services/service_myspeed/test/myspeed_api_test.dart index c01a4512..3db0baf8 100644 --- a/services/service_myspeed/test/myspeed_api_test.dart +++ b/services/service_myspeed/test/myspeed_api_test.dart @@ -137,11 +137,17 @@ void main() { expect(status.message, 'Ookla test in progress'); }); - test('getHistory calls api/speedtests with hours param', () async { + test('getSpeedtests sends the window, the limit and the start cursor', + () async { final dio = Dio(); dio.httpClientAdapter = _FakeAdapter( handler: (options) { - expect(options.queryParameters['hours'], 48); + expect(options.path, endsWith('api/speedtests')); + expect(options.queryParameters, { + 'hours': 48, + 'limit': 1000, + 'start': 160, + }); return ResponseBody.fromString( '[{"id": 1, "download": 100.0, "upload": 20.0, "ping": 10.0}]', 200, @@ -153,18 +159,22 @@ void main() { ); final api = MySpeedApi(dio); - final history = await api.getHistory(hours: 48); + final history = + await api.getSpeedtests(hours: 48, limit: 1000, start: 160); expect(history.length, 1); expect(history.first.download, 100.0); }); - test('get24HourSpeedtests calls api/speedtests with hours=24 and hour=24 params', () async { + test('get24HourSpeedtests asks for the whole day', () async { + // The server caps a list at 10 rows unless a limit is sent. final dio = Dio(); dio.httpClientAdapter = _FakeAdapter( handler: (options) { - expect(options.queryParameters['hours'], 24); - expect(options.queryParameters['hour'], 24); + expect(options.queryParameters, { + 'hours': 24, + 'limit': MySpeedApi.pageLimit, + }); return ResponseBody.fromString( '[{"id": 2, "download": 250.0, "upload": 50.0, "ping": 8.0}]', 200, @@ -203,18 +213,18 @@ void main() { expect(config.provider, 'ookla'); }); - test('runSpeedtest sends POST', () async { + test('runSpeedtest posts to api/speedtests/run', () async { final dio = Dio(); dio.httpClientAdapter = _FakeAdapter( handler: (options) { expect(options.method, 'POST'); - return ResponseBody.fromString('{"success": true}', 200); + expect(options.path, endsWith('api/speedtests/run')); + return ResponseBody.fromString('{"message": "Speedtest started"}', 200); }, ); final api = MySpeedApi(dio); - final result = await api.runSpeedtest(); - expect(result, isTrue); + await api.runSpeedtest(); }); test('getSpeedtestById returns test when found', () async { From 1df545df6ee7b07c84403629f059fe689d2ffb63 Mon Sep 17 00:00:00 2001 From: retransmit Date: Sun, 20 Sep 2026 21:05:09 +0530 Subject: [PATCH 19/23] fix(service_myspeed): sentence-case the labels, count one test as one, and poll the day's list less Labels follow the app's sentence case. The status poll re-reads the day's list when a run ends and once a minute, instead of every five seconds, and no longer touches its ref after the widget is gone. --- app/test/new_services_render_test.dart | 2 +- .../lib/src/tabs/myspeed_config_tab.dart | 16 +++++------ .../lib/src/tabs/myspeed_history_tab.dart | 6 ++-- .../lib/src/tabs/myspeed_status_tab.dart | 28 +++++++++++++------ .../lib/src/widgets/myspeed_test_card.dart | 2 +- .../test/myspeed_home_test.dart | 18 ++++++------ 6 files changed, 41 insertions(+), 31 deletions(-) diff --git a/app/test/new_services_render_test.dart b/app/test/new_services_render_test.dart index 4f0eba36..81c90c8d 100644 --- a/app/test/new_services_render_test.dart +++ b/app/test/new_services_render_test.dart @@ -414,7 +414,7 @@ void main() { pumps: 2, ); - expect(find.text('Execution Status'), findsOneWidget); + expect(find.text('Execution status'), findsOneWidget); expect(find.text('Idle'), findsOneWidget); expect(find.byType(NavigationBar), findsOneWidget); }); diff --git a/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart index fb119499..7a31b4f8 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_config_tab.dart @@ -90,7 +90,7 @@ class _MySpeedConfigTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Configuration Overview', + 'Configuration overview', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -100,7 +100,7 @@ class _MySpeedConfigTabState extends ConsumerState { _overviewRow( context, icon: Icons.schedule_rounded, - label: 'Cron Schedule', + label: 'Cron schedule', value: config.cron!, color: colors.primary, ), @@ -110,7 +110,7 @@ class _MySpeedConfigTabState extends ConsumerState { _overviewRow( context, icon: Icons.hub_rounded, - label: 'Test Provider', + label: 'Test provider', value: config.provider!, color: colors.tertiary, ), @@ -129,7 +129,7 @@ class _MySpeedConfigTabState extends ConsumerState { _overviewRow( context, icon: Icons.settings_ethernet_rounded, - label: 'Active Properties', + label: 'Active properties', value: '${config.entries.length} keys loaded', color: colors.primary, ), @@ -160,7 +160,7 @@ class _MySpeedConfigTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Storage & Retention', + 'Storage and retention', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -188,7 +188,7 @@ class _MySpeedConfigTabState extends ConsumerState { _overviewRow( context, icon: Icons.storage_rounded, - label: 'Database Size', + label: 'Database size', value: storage.formattedSize, color: colors.primary, ), @@ -196,7 +196,7 @@ class _MySpeedConfigTabState extends ConsumerState { _overviewRow( context, icon: Icons.analytics_outlined, - label: 'Tests Stored', + label: 'Tests stored', value: '${storage.testCount ?? 0}', color: colors.secondary, ), @@ -205,7 +205,7 @@ class _MySpeedConfigTabState extends ConsumerState { _overviewRow( context, icon: Icons.auto_delete_outlined, - label: 'Data Retention', + label: 'Data retention', value: '$retention days', color: colors.tertiary, ), diff --git a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart index e2265a47..8e9de79d 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_history_tab.dart @@ -249,7 +249,7 @@ class _MySpeedHistoryTabState extends ConsumerState { padding: EdgeInsets.symmetric(vertical: Insets.xl), child: EmptyView( icon: Icons.history_rounded, - title: 'No Speedtests', + title: 'No speedtests', message: 'No speedtests recorded on this instance.', ), ); @@ -299,7 +299,7 @@ class _MySpeedHistoryTabState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Historical Summary', + 'Historical summary', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -314,7 +314,7 @@ class _MySpeedHistoryTabState extends ConsumerState { ), ), child: Text( - '${tests.length} tests', + tests.length == 1 ? '1 test' : '${tests.length} tests', style: theme.textTheme.labelSmall?.copyWith( color: colors.primary, fontWeight: FontWeight.bold, diff --git a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart index c6b65cfa..34ca8cae 100644 --- a/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart +++ b/services/service_myspeed/lib/src/tabs/myspeed_status_tab.dart @@ -30,6 +30,7 @@ class MySpeedStatusTab extends ConsumerStatefulWidget { class _MySpeedStatusTabState extends ConsumerState { Timer? _pollingTimer; bool _isLocallyRunning = false; + int _ticks = 0; @override void initState() { @@ -49,7 +50,13 @@ class _MySpeedStatusTabState extends ConsumerState { if (activeTab != 0 && !wasRunning) return; ref.invalidate(myspeedStatusProvider(widget.instance)); - ref.invalidate(myspeed24HourTestsProvider(widget.instance)); + // The day's list is a few hundred rows. It changes when a run ends, so + // it is re-read then, and once a minute for a scheduled run the status + // poll happened to miss. + _ticks++; + if (_ticks % 12 == 0) { + ref.invalidate(myspeed24HourTestsProvider(widget.instance)); + } MySpeedStatus? newStatus; try { @@ -68,8 +75,11 @@ class _MySpeedStatusTabState extends ConsumerState { } } - // If speedtest just transitioned from running to idle, update history diff + // The widget may have gone while the status was in flight, and its ref + // with it. + if (!mounted) return; if (wasRunning && !isNowRunning) { + ref.invalidate(myspeed24HourTestsProvider(widget.instance)); await ref.read(myspeedHistoryProvider(widget.instance).notifier).fetchDiff(); } } @@ -84,7 +94,7 @@ class _MySpeedStatusTabState extends ConsumerState { final bool? confirmed = await showDialog( context: context, builder: (BuildContext ctx) => AlertDialog( - title: const Text('Run Speedtest'), + title: const Text('Run speedtest'), content: Text('Start a new speedtest on ${widget.instance.name}?'), actions: [ TextButton( @@ -218,13 +228,13 @@ class _MySpeedStatusTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Execution Status', + 'Execution status', style: theme.textTheme.labelMedium?.copyWith( color: colors.onSurfaceVariant, ), ), Text( - isRunning ? 'Speedtest Running' : 'Idle', + isRunning ? 'Speedtest running' : 'Idle', overflow: TextOverflow.ellipsis, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, @@ -297,7 +307,7 @@ class _MySpeedStatusTabState extends ConsumerState { height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Run Test'), + : const Text('Run test'), ), ), ], @@ -332,7 +342,7 @@ class _MySpeedStatusTabState extends ConsumerState { Row( children: [ Text( - 'Most Recent Result', + 'Most recent result', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -485,7 +495,7 @@ class _MySpeedStatusTabState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Recent Results', + 'Recent results', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -501,7 +511,7 @@ class _MySpeedStatusTabState extends ConsumerState { borderRadius: BorderRadius.circular(10), ), child: Text( - '${tests.length} tests', + tests.length == 1 ? '1 test' : '${tests.length} tests', style: theme.textTheme.labelSmall?.copyWith( color: colors.onPrimaryContainer, fontWeight: FontWeight.w600, diff --git a/services/service_myspeed/lib/src/widgets/myspeed_test_card.dart b/services/service_myspeed/lib/src/widgets/myspeed_test_card.dart index 229aa127..e97cbddf 100644 --- a/services/service_myspeed/lib/src/widgets/myspeed_test_card.dart +++ b/services/service_myspeed/lib/src/widgets/myspeed_test_card.dart @@ -22,7 +22,7 @@ class MySpeedTestCard extends StatelessWidget { final String timeStr = test.formattedDate.isNotEmpty ? test.formattedDate - : 'Recent Test'; + : 'Recent test'; return Card( elevation: 0, diff --git a/services/service_myspeed/test/myspeed_home_test.dart b/services/service_myspeed/test/myspeed_home_test.dart index 5c2f0132..4eea6722 100644 --- a/services/service_myspeed/test/myspeed_home_test.dart +++ b/services/service_myspeed/test/myspeed_home_test.dart @@ -113,19 +113,19 @@ void main() { await tester.pumpAndSettle(); - expect(find.text('Execution Status'), findsOneWidget); + expect(find.text('Execution status'), findsOneWidget); expect(find.text('Idle'), findsOneWidget); - expect(find.text('Run Test'), findsOneWidget); + expect(find.text('Run test'), findsOneWidget); expect(find.byIcon(Icons.bolt_rounded), findsNothing); expect(find.byIcon(Icons.bolt), findsNothing); - expect(find.text('Most Recent Result'), findsOneWidget); + expect(find.text('Most recent result'), findsOneWidget); expect(find.text('#1'), findsAtLeastNWidgets(1)); await tester.drag(find.byType(ListView), const Offset(0, -300)); await tester.pumpAndSettle(); - expect(find.text('Recent Results'), findsOneWidget); - expect(find.text('1 tests'), findsOneWidget); + expect(find.text('Recent results'), findsOneWidget); + expect(find.text('1 test'), findsOneWidget); expect(find.text('320.5'), findsAtLeastNWidgets(1)); // from dedicated metric box }); @@ -142,8 +142,8 @@ void main() { await tester.pumpAndSettle(); - expect(find.text('Historical Summary'), findsOneWidget); - expect(find.text('1 tests'), findsOneWidget); + expect(find.text('Historical summary'), findsOneWidget); + expect(find.text('1 test'), findsOneWidget); expect(find.byType(TextField), findsOneWidget); expect(find.text('#1'), findsOneWidget); expect(find.text('320.5'), findsNWidgets(2)); // summary + card @@ -162,8 +162,8 @@ void main() { await tester.pumpAndSettle(); - expect(find.text('Configuration Overview'), findsOneWidget); - expect(find.text('Storage & Retention'), findsOneWidget); + expect(find.text('Configuration overview'), findsOneWidget); + expect(find.text('Storage and retention'), findsOneWidget); expect(find.text('1.0 MB'), findsOneWidget); // detail row expect(find.text('42'), findsOneWidget); expect(find.text('0 * * * *'), findsNWidgets(2)); From b55c1829715637263a69c313a2627f199a3aebd1 Mon Sep 17 00:00:00 2001 From: retransmit Date: Sun, 20 Sep 2026 21:05:09 +0530 Subject: [PATCH 20/23] docs: list MySpeed in the README and STATUS.md --- README.md | 3 ++- STATUS.md | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cd06836c..4d1b3acd 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ The central courtyard for your self-hosted media stack. One Android app that fronts Sonarr, Radarr, Lidarr, Prowlarr, Bazarr, Seerr, Tautulli, Tracearr, Jellyfin, Emby, Plex, qBittorrent, Deluge, -Transmission, rTorrent, SABnzbd, NZBGet, Glances, Speedtest Tracker, Beszel, dashdot and Unraid - and routes every request through the right URL +Transmission, rTorrent, SABnzbd, NZBGet, Glances, Speedtest Tracker, MySpeed, Beszel, dashdot and Unraid - and routes every request through the right URL whether you're on the home Wi-Fi or out in the world. **[Website][site]** - screenshots and a tour, no install needed. @@ -73,6 +73,7 @@ each one covers: | NZBGet | queue with reorder/priority/category, add NZB, speed limit, history | | Glances | CPU/memory/network/disk monitoring | | Speedtest Tracker | latest result, history chart, dashboard, confirmed remote test runs | +| MySpeed | status and manual runs, the day's results, history with averages | | Beszel | systems list, live metrics, per-system detail screen | | dashdot | live CPU, memory, disk, and GPU usage with a system-info tab | | Unraid | array and disk health, system with per-core load, Docker, VM control | diff --git a/STATUS.md b/STATUS.md index 8293574f..0d49c505 100644 --- a/STATUS.md +++ b/STATUS.md @@ -121,6 +121,11 @@ Atrium is a **controller** app. Video playback was removed by design and health, system with per-core CPU load and an About card, Docker containers with a detail sheet and start/stop/pause/resume, and virtual machines with start, shut down, pause, resume, reboot, force stop and reset +- **MySpeed** (added 2026-09-20 by lxBlazarxl, live-verified against 1.0.9): + execution status with a manual run, the last 24 hours of results, a + history with averages and a search by test id, the server's config and + storage figures. No password support yet, so a MySpeed with a password + set cannot be added - **Speedtest Tracker** (live-verified): authenticated 1.1+ result history, latest metrics, combined download / upload chart, multi-instance dashboard widget, and confirmed 1.6+ remote runs with queued/running/terminal-state From d786a5276bbc378a630e239fc7d724e8ba0487c0 Mon Sep 17 00:00:00 2001 From: retransmit Date: Sun, 20 Sep 2026 21:10:21 +0530 Subject: [PATCH 21/23] fix(app): finish the MySpeed early return the merge cut short --- app/lib/src/screens/service_detail_screen.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/lib/src/screens/service_detail_screen.dart b/app/lib/src/screens/service_detail_screen.dart index 376bc471..c8660dfa 100644 --- a/app/lib/src/screens/service_detail_screen.dart +++ b/app/lib/src/screens/service_detail_screen.dart @@ -126,6 +126,13 @@ class _ServiceDetailScreenState extends ConsumerState { } if (instance.kind == ServiceKind.myspeed) { return MySpeedHome( + instance: instance, + drawer: ServicesDrawer( + instances: ref.watch(activeInstancesProvider), + profile: ref.watch(activeProfileProvider), + ), + ); + } if (instance.kind == ServiceKind.transmission) { return TransmissionHome( instance: instance, From 1f1f900a1eae54614dff779177f0f9de648d1d96 Mon Sep 17 00:00:00 2001 From: lxBlazarxl Date: Sun, 20 Sep 2026 22:34:02 +0530 Subject: [PATCH 22/23] feat(myspeed): add authentication support in instance form and networking layer Support optional password authentication for MySpeed across models, networking, health probes, and UI. - Update ServiceKind.myspeed auth style from none to apiKey. - Send password and x-password headers via AuthInterceptor when apiKey is configured. - Register password and x-password headers in serviceAuthHeaderNames. - Switch MySpeed health probe mode to _HealthMode.authed on api/speedtests. - Render obscured optional password field in InstanceFormScreen with helper text for creation and edit settings. - Add unit and widget tests covering interceptor headers, health response interpretation, and form hydration. --- app/lib/src/screens/instance_form_screen.dart | 19 ++++ app/test/myspeed_form_auth_test.dart | 103 ++++++++++++++++++ .../core_models/lib/src/service_kind.dart | 3 +- .../core_models/test/service_kind_test.dart | 4 +- .../lib/src/auth_interceptor.dart | 7 ++ .../lib/src/service_auth_headers.dart | 6 +- .../lib/src/service_health.dart | 2 +- .../test/auth_interceptor_test.dart | 41 +++++++ ...lobal_headers_reach_every_client_test.dart | 10 +- .../test/service_auth_headers_test.dart | 13 +++ .../test/service_health_test.dart | 27 +++++ 11 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 app/test/myspeed_form_auth_test.dart diff --git a/app/lib/src/screens/instance_form_screen.dart b/app/lib/src/screens/instance_form_screen.dart index c3b5bdab..b23d8403 100644 --- a/app/lib/src/screens/instance_form_screen.dart +++ b/app/lib/src/screens/instance_form_screen.dart @@ -560,6 +560,24 @@ class _InstanceFormScreenState extends ConsumerState { List _authFields() { switch (_kind.authStyle) { case AuthStyle.apiKey: + if (_kind == ServiceKind.myspeed) { + return [ + TextFormField( + controller: _apiKey, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Password (optional)', + helperText: + 'Leave empty if password protection is disabled on your MySpeed instance.', + ), + obscureText: true, + autocorrect: false, + enableSuggestions: false, + onChanged: _clearConnectionTest, + validator: (String? v) => null, + ), + ]; + } // Gluetun's control server can run without auth, through a role with // auth = "none", and then there is no key to give. final bool keyOptional = _kind == ServiceKind.gluetun; @@ -574,6 +592,7 @@ class _InstanceFormScreenState extends ConsumerState { : null, ), autocorrect: false, + onChanged: _clearConnectionTest, validator: (String? v) => !keyOptional && (v == null || v.trim().isEmpty) ? 'Required' diff --git a/app/test/myspeed_form_auth_test.dart b/app/test/myspeed_form_auth_test.dart new file mode 100644 index 00000000..9f7fac45 --- /dev/null +++ b/app/test/myspeed_form_auth_test.dart @@ -0,0 +1,103 @@ +import 'package:atrium/src/screens/instance_form_screen.dart'; +import 'package:core_models/core_models.dart'; +import 'package:core_profile/core_profile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Verifies MySpeed password authentication in InstanceFormScreen. +void main() { + FormField passwordField(WidgetTester tester) => + tester.widget( + find.ancestor( + of: find.text('Password (optional)'), + matching: find.byType(TextFormField), + ), + ); + + testWidgets('MySpeed form shows obscured optional password field', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(900, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const ProviderScope(child: MaterialApp(home: InstanceFormScreen())), + ); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('MySpeed - Internet speed'), + 200, + scrollable: find.byType(Scrollable).last, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('MySpeed - Internet speed')); + await tester.pumpAndSettle(); + + final FormField field = passwordField(tester); + expect(field.validator!(''), isNull); + + final TextField textField = tester.widget( + find.descendant( + of: find.ancestor( + of: find.text('Password (optional)'), + matching: find.byType(TextFormField), + ), + matching: find.byType(TextField), + ), + ); + expect(textField.obscureText, isTrue); + expect( + find.textContaining('Leave empty if password protection is disabled'), + findsOneWidget, + ); + }); + + testWidgets('MySpeed edit form hydrates existing password', + (WidgetTester tester) async { + tester.view.physicalSize = const Size(900, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const Instance existing = Instance( + id: 'myspeed-instance-1', + name: 'Home MySpeed', + kind: ServiceKind.myspeed, + localUrl: 'http://192.168.1.50:5216', + externalUrl: '', + urlMode: UrlMode.auto, + auth: InstanceAuth.apiKey(apiKey: 'super-secret-password'), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + instanceByIdProvider(existing.id).overrideWithValue(existing), + ], + child: const MaterialApp( + home: InstanceFormScreen(instanceId: 'myspeed-instance-1'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Edit service'), findsOneWidget); + expect(find.text('Password (optional)'), findsOneWidget); + + final TextField textField = tester.widget( + find.descendant( + of: find.ancestor( + of: find.text('Password (optional)'), + matching: find.byType(TextFormField), + ), + matching: find.byType(TextField), + ), + ); + expect(textField.controller?.text, 'super-secret-password'); + expect(textField.obscureText, isTrue); + }); +} diff --git a/packages/core_models/lib/src/service_kind.dart b/packages/core_models/lib/src/service_kind.dart index 2ccdf6ec..5c94bf37 100644 --- a/packages/core_models/lib/src/service_kind.dart +++ b/packages/core_models/lib/src/service_kind.dart @@ -163,6 +163,7 @@ extension ServiceKindX on ServiceKind { ServiceKind.lidarr || ServiceKind.unraid || ServiceKind.gluetun || + ServiceKind.myspeed || ServiceKind.ombi => AuthStyle.apiKey, // Transmission and rTorrent both use HTTP Basic, and for both it is @@ -183,7 +184,7 @@ extension ServiceKindX on ServiceKind { ServiceKind.glances => AuthStyle.none, ServiceKind.speedtestTracker => AuthStyle.bearerToken, ServiceKind.beszel => AuthStyle.userPass, - ServiceKind.dashdot || ServiceKind.myspeed => AuthStyle.none, + ServiceKind.dashdot => AuthStyle.none, }; /// Broad role of the service in the stack - used for grouping in the diff --git a/packages/core_models/test/service_kind_test.dart b/packages/core_models/test/service_kind_test.dart index 7083e494..266f0b10 100644 --- a/packages/core_models/test/service_kind_test.dart +++ b/packages/core_models/test/service_kind_test.dart @@ -49,12 +49,12 @@ void main() { expect(ServiceKind.ombi.acceptsTorrents, isFalse); }); - test('MySpeed is registered as a no-auth analytics service', () { + test('MySpeed is registered as an apiKey analytics service', () { expect(ServiceKind.myspeed.displayName, 'MySpeed'); expect(ServiceKind.myspeed.tagline, 'Internet speed'); expect(ServiceKind.myspeed.isBeta, isFalse); expect(ServiceKind.myspeed.defaultPort, 5216); - expect(ServiceKind.myspeed.authStyle, AuthStyle.none); + expect(ServiceKind.myspeed.authStyle, AuthStyle.apiKey); expect(ServiceKind.myspeed.role, ServiceRole.analytics); expect(ServiceKind.myspeed.acceptsTorrents, isFalse); }); diff --git a/packages/core_networking/lib/src/auth_interceptor.dart b/packages/core_networking/lib/src/auth_interceptor.dart index 1966a4ed..03fd6624 100644 --- a/packages/core_networking/lib/src/auth_interceptor.dart +++ b/packages/core_networking/lib/src/auth_interceptor.dart @@ -55,6 +55,13 @@ class AuthInterceptor extends Interceptor { case ServiceKind.ombi: // Ombi reads only its own header; X-Api-Key gets a 401. options.headers['ApiKey'] = apiKey; + case ServiceKind.myspeed: + // MySpeed checks 'password' and 'x-password' headers when password + // protection is configured on the instance. + if (apiKey.isNotEmpty) { + options.headers['password'] = apiKey; + options.headers['x-password'] = Uri.encodeComponent(apiKey); + } case _: options.headers['X-Api-Key'] = apiKey; } diff --git a/packages/core_networking/lib/src/service_auth_headers.dart b/packages/core_networking/lib/src/service_auth_headers.dart index 98748a5d..698adba0 100644 --- a/packages/core_networking/lib/src/service_auth_headers.dart +++ b/packages/core_networking/lib/src/service_auth_headers.dart @@ -13,7 +13,7 @@ import 'package:core_models/core_models.dart'; /// agrees with what the interceptor actually sends for every kind. Set serviceAuthHeaderNames(ServiceKind kind, InstanceAuth auth) { switch (auth) { - case InstanceAuthApiKey(): + case InstanceAuthApiKey(:final String apiKey): switch (kind) { case ServiceKind.speedtestTracker || ServiceKind.tracearr: return const {'Authorization', 'Accept'}; @@ -22,6 +22,10 @@ Set serviceAuthHeaderNames(ServiceKind kind, InstanceAuth auth) { return const {}; case ServiceKind.ombi: return const {'ApiKey'}; + case ServiceKind.myspeed: + return apiKey.isEmpty + ? const {} + : const {'password', 'x-password'}; case _: return const {'X-Api-Key'}; } diff --git a/packages/core_networking/lib/src/service_health.dart b/packages/core_networking/lib/src/service_health.dart index dac8b06e..c850bb81 100644 --- a/packages/core_networking/lib/src/service_health.dart +++ b/packages/core_networking/lib/src/service_health.dart @@ -114,7 +114,7 @@ enum _HealthMode { // see the navidrome arm of [interpretServiceHealthResponse]. return (path: 'rest/ping.view', mode: _HealthMode.authed); case ServiceKind.myspeed: - return (path: 'api/speedtests', mode: _HealthMode.publicEndpoint); + return (path: 'api/speedtests', mode: _HealthMode.authed); } } diff --git a/packages/core_networking/test/auth_interceptor_test.dart b/packages/core_networking/test/auth_interceptor_test.dart index 72c4bf2c..b8b05abd 100644 --- a/packages/core_networking/test/auth_interceptor_test.dart +++ b/packages/core_networking/test/auth_interceptor_test.dart @@ -52,6 +52,47 @@ void main() { expect(request.queryParameters.values, isNot(contains(token))); expect(request.uri.toString(), isNot(contains(token))); }); + + test('MySpeed password is sent as password and x-password headers', () async { + const String password = 'my secret @ password!'; + final _RecordingAdapter adapter = _RecordingAdapter(); + final Dio dio = Dio(BaseOptions(baseUrl: 'https://myspeed.example.test/')) + ..httpClientAdapter = adapter + ..interceptors.add( + const AuthInterceptor( + kind: ServiceKind.myspeed, + auth: InstanceAuth.apiKey(apiKey: password), + ), + ); + + await dio.get('api/speedtests'); + + final RequestOptions request = adapter.request!; + expect(request.headers['password'], password); + expect(request.headers['x-password'], Uri.encodeComponent(password)); + expect(request.headers.containsKey('X-Api-Key'), isFalse); + expect(request.headers.containsKey('Authorization'), isFalse); + }); + + test('MySpeed with empty password sets no auth headers', () async { + final _RecordingAdapter adapter = _RecordingAdapter(); + final Dio dio = Dio(BaseOptions(baseUrl: 'https://myspeed.example.test/')) + ..httpClientAdapter = adapter + ..interceptors.add( + const AuthInterceptor( + kind: ServiceKind.myspeed, + auth: InstanceAuth.apiKey(apiKey: ''), + ), + ); + + await dio.get('api/speedtests'); + + final RequestOptions request = adapter.request!; + expect(request.headers.containsKey('password'), isFalse); + expect(request.headers.containsKey('x-password'), isFalse); + expect(request.headers.containsKey('X-Api-Key'), isFalse); + expect(request.headers.containsKey('Authorization'), isFalse); + }); } class _RecordingAdapter implements HttpClientAdapter { diff --git a/packages/core_networking/test/global_headers_reach_every_client_test.dart b/packages/core_networking/test/global_headers_reach_every_client_test.dart index 739583c9..dd0549d2 100644 --- a/packages/core_networking/test/global_headers_reach_every_client_test.dart +++ b/packages/core_networking/test/global_headers_reach_every_client_test.dart @@ -114,9 +114,13 @@ class _RecordingAdapter implements HttpClientAdapter { Future? cancelFuture, ) async { seen = options.headers; - return ResponseBody.fromString('{}', 200, headers: >{ - Headers.contentTypeHeader: [Headers.jsonContentType], - }); + return ResponseBody.fromString( + '{}', + 200, + headers: >{ + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); } @override diff --git a/packages/core_networking/test/service_auth_headers_test.dart b/packages/core_networking/test/service_auth_headers_test.dart index eb1d6cae..01decc04 100644 --- a/packages/core_networking/test/service_auth_headers_test.dart +++ b/packages/core_networking/test/service_auth_headers_test.dart @@ -20,6 +20,7 @@ void main() { } const InstanceAuth apiKey = InstanceAuth.apiKey(apiKey: 'k'); + const InstanceAuth blankApiKey = InstanceAuth.apiKey(apiKey: ''); const InstanceAuth plex = InstanceAuth.plexToken(token: 't'); const InstanceAuth userPass = InstanceAuth.userPass(username: 'u', password: 'p'); @@ -30,6 +31,7 @@ void main() { for (final ServiceKind kind in ServiceKind.values) { for (final InstanceAuth auth in [ apiKey, + blankApiKey, plex, userPass, blankUserPass, @@ -43,6 +45,17 @@ void main() { } }); + test('MySpeed sets password and x-password headers only when key provided', () { + expect( + serviceAuthHeaderNames(ServiceKind.myspeed, apiKey), + {'password', 'x-password'}, + ); + expect( + serviceAuthHeaderNames(ServiceKind.myspeed, blankApiKey), + isEmpty, + ); + }); + test('the five kinds that spend Authorization on themselves are named', () { // These are the ones where a user's own Authorization header never // reaches the wire, which is the whole reason the warning exists. diff --git a/packages/core_networking/test/service_health_test.dart b/packages/core_networking/test/service_health_test.dart index 84a17720..335fcd5f 100644 --- a/packages/core_networking/test/service_health_test.dart +++ b/packages/core_networking/test/service_health_test.dart @@ -93,6 +93,33 @@ void main() { ); } }); + + test('MySpeed 200 is ok, 401/403 auth error is warning', () { + expect( + interpretServiceHealthResponse( + ServiceKind.myspeed, + 200, + [], + ), + Health.ok, + ); + expect( + interpretServiceHealthResponse( + ServiceKind.myspeed, + 401, + {'message': 'Please provide the correct password in the header'}, + ), + Health.warning, + ); + expect( + interpretServiceHealthResponse( + ServiceKind.myspeed, + 403, + {'message': 'Forbidden'}, + ), + Health.warning, + ); + }); }); } From 1b0a4e9c0e4e5b3dc784457a0b8935e716f9e249 Mon Sep 17 00:00:00 2001 From: retransmit Date: Sun, 20 Sep 2026 23:01:49 +0530 Subject: [PATCH 23/23] fix(core_networking): send MySpeed's raw password header only when Dart will let it through MySpeed 1.0.9 reads a raw password header and newer builds prefer the URL-encoded x-password one, so both go out. Dart's HttpHeaders throws on a value outside printable ASCII, from inside the request, so a password with an accented or non-Latin character broke every call instead of merely missing the older header. The raw one now goes only when it fits; the header-name map says the same. --- .../lib/src/auth_interceptor.dart | 11 +++++++--- .../lib/src/service_auth_headers.dart | 15 ++++++++++--- .../test/auth_interceptor_test.dart | 22 +++++++++++++++++++ .../test/service_auth_headers_test.dart | 7 ++++++ 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/core_networking/lib/src/auth_interceptor.dart b/packages/core_networking/lib/src/auth_interceptor.dart index 03fd6624..de92c720 100644 --- a/packages/core_networking/lib/src/auth_interceptor.dart +++ b/packages/core_networking/lib/src/auth_interceptor.dart @@ -5,6 +5,8 @@ import 'package:core_models/core_models.dart'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; +import 'service_auth_headers.dart'; + /// Adds the auth header(s) appropriate for the [Instance]'s service kind. /// /// Decoder for the various conventions across the stack: @@ -56,11 +58,14 @@ class AuthInterceptor extends Interceptor { // Ombi reads only its own header; X-Api-Key gets a 401. options.headers['ApiKey'] = apiKey; case ServiceKind.myspeed: - // MySpeed checks 'password' and 'x-password' headers when password - // protection is configured on the instance. + // MySpeed 1.0.9 reads a raw 'password' header; newer builds + // prefer a URL-encoded 'x-password' and fall back to the raw + // one. The raw header only goes when Dart will let it through: + // a password outside printable ASCII would otherwise throw + // inside every request. if (apiKey.isNotEmpty) { - options.headers['password'] = apiKey; options.headers['x-password'] = Uri.encodeComponent(apiKey); + if (fitsHeaderValue(apiKey)) options.headers['password'] = apiKey; } case _: options.headers['X-Api-Key'] = apiKey; diff --git a/packages/core_networking/lib/src/service_auth_headers.dart b/packages/core_networking/lib/src/service_auth_headers.dart index 698adba0..525f134f 100644 --- a/packages/core_networking/lib/src/service_auth_headers.dart +++ b/packages/core_networking/lib/src/service_auth_headers.dart @@ -23,9 +23,10 @@ Set serviceAuthHeaderNames(ServiceKind kind, InstanceAuth auth) { case ServiceKind.ombi: return const {'ApiKey'}; case ServiceKind.myspeed: - return apiKey.isEmpty - ? const {} - : const {'password', 'x-password'}; + if (apiKey.isEmpty) return const {}; + return fitsHeaderValue(apiKey) + ? const {'password', 'x-password'} + : const {'x-password'}; case _: return const {'X-Api-Key'}; } @@ -121,3 +122,11 @@ String? headerConflictWarning(String headerName, List instances) { 'it, such as Authelia. nginx basic auth only reads this header.'); return buffer.toString(); } + +/// Whether [value] can travel as an HTTP header value at all. Dart's +/// HttpHeaders refuses anything outside printable ASCII, and it refuses +/// it by throwing from inside the request, so a secret that does not fit +/// has to go some other way (MySpeed reads a URL-encoded copy). +bool fitsHeaderValue(String value) => _printableAscii.hasMatch(value); + +final RegExp _printableAscii = RegExp(r'^[ -~]*$'); diff --git a/packages/core_networking/test/auth_interceptor_test.dart b/packages/core_networking/test/auth_interceptor_test.dart index b8b05abd..4e919954 100644 --- a/packages/core_networking/test/auth_interceptor_test.dart +++ b/packages/core_networking/test/auth_interceptor_test.dart @@ -74,6 +74,28 @@ void main() { expect(request.headers.containsKey('Authorization'), isFalse); }); + test('a MySpeed password outside ASCII travels only as x-password', + () async { + // Dart's HttpHeaders throws on such a value, so the raw header would + // fail every request rather than merely be ignored. + const String password = 'pässwörd'; + final _RecordingAdapter adapter = _RecordingAdapter(); + final Dio dio = Dio(BaseOptions(baseUrl: 'https://myspeed.example.test/')) + ..httpClientAdapter = adapter + ..interceptors.add( + const AuthInterceptor( + kind: ServiceKind.myspeed, + auth: InstanceAuth.apiKey(apiKey: password), + ), + ); + + await dio.get('api/speedtests'); + + final RequestOptions request = adapter.request!; + expect(request.headers.containsKey('password'), isFalse); + expect(request.headers['x-password'], Uri.encodeComponent(password)); + }); + test('MySpeed with empty password sets no auth headers', () async { final _RecordingAdapter adapter = _RecordingAdapter(); final Dio dio = Dio(BaseOptions(baseUrl: 'https://myspeed.example.test/')) diff --git a/packages/core_networking/test/service_auth_headers_test.dart b/packages/core_networking/test/service_auth_headers_test.dart index 01decc04..0b832479 100644 --- a/packages/core_networking/test/service_auth_headers_test.dart +++ b/packages/core_networking/test/service_auth_headers_test.dart @@ -54,6 +54,13 @@ void main() { serviceAuthHeaderNames(ServiceKind.myspeed, blankApiKey), isEmpty, ); + expect( + serviceAuthHeaderNames( + ServiceKind.myspeed, + const InstanceAuth.apiKey(apiKey: 'pässwörd'), + ), + {'x-password'}, + ); }); test('the five kinds that spend Authorization on themselves are named', () {