From 5687590b88ce7097bec2d5c53fb6ea97445af2a3 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Fri, 16 Jan 2026 00:52:20 -0800 Subject: [PATCH 01/24] Attempt --- .../device_cache_migration_service.dart | 254 +++++++++++++ .../models/device_model_sealed.freezed.dart | 339 ++++-------------- .../data/models/device_model_sealed.g.dart | 30 +- 3 files changed, 331 insertions(+), 292 deletions(-) create mode 100644 lib/core/services/device_cache_migration_service.dart diff --git a/lib/core/services/device_cache_migration_service.dart b/lib/core/services/device_cache_migration_service.dart new file mode 100644 index 0000000..86d1e6f --- /dev/null +++ b/lib/core/services/device_cache_migration_service.dart @@ -0,0 +1,254 @@ +import 'dart:convert'; + +import 'package:rgnets_fdk/core/config/logger_config.dart'; +import 'package:rgnets_fdk/core/services/storage_service.dart'; +import 'package:rgnets_fdk/features/devices/data/datasources/typed_device_local_data_source.dart'; +import 'package:rgnets_fdk/features/devices/data/models/device_model_sealed.dart'; + +/// One-time migration service to convert old unified device cache +/// to new type-specific caches. +/// +/// Old format: +/// - `device_index` → List of device IDs +/// - `cached_device_{id}` → Individual device JSON +/// +/// New format: +/// - `cached_ap_devices` → JSON array of all APs +/// - `cached_ont_devices` → JSON array of all ONTs +/// - `cached_switch_devices` → JSON array of all Switches +/// - `cached_wlan_devices` → JSON array of all WLANs +class DeviceCacheMigrationService { + DeviceCacheMigrationService({ + required this.storageService, + required this.apDataSource, + required this.ontDataSource, + required this.switchDataSource, + required this.wlanDataSource, + }); + + final StorageService storageService; + final APLocalDataSource apDataSource; + final ONTLocalDataSource ontDataSource; + final SwitchLocalDataSource switchDataSource; + final WLANLocalDataSource wlanDataSource; + final _logger = LoggerConfig.getLogger(); + + // Old cache keys + static const String _oldIndexKey = 'device_index'; + static const String _oldDeviceKeyPrefix = 'cached_device_'; + static const String _oldTimestampKey = 'devices_cache_timestamp'; + static const String _migrationCompleteKey = 'device_cache_migration_v2_complete'; + + /// Check if migration is needed + Future needsMigration() async { + // Already migrated + final migrated = storageService.getString(_migrationCompleteKey); + if (migrated == 'true') { + return false; + } + + // Check if old cache exists + final oldIndex = storageService.getString(_oldIndexKey); + return oldIndex != null && oldIndex.isNotEmpty; + } + + /// Run the migration + Future migrate() async { + if (!await needsMigration()) { + return const MigrationResult( + success: true, + migrated: false, + message: 'Migration not needed', + ); + } + + _logger.i('Starting device cache migration...'); + + try { + // Load old device index + final indexJson = storageService.getString(_oldIndexKey); + if (indexJson == null) { + return const MigrationResult( + success: true, + migrated: false, + message: 'No old cache found', + ); + } + + final index = (json.decode(indexJson) as List).cast(); + _logger.d('Found ${index.length} devices in old cache'); + + // Collect devices by type + final apDevices = []; + final ontDevices = []; + final switchDevices = []; + final wlanDevices = []; + final failedIds = []; + + for (final id in index) { + final deviceJson = storageService.getString('$_oldDeviceKeyPrefix$id'); + if (deviceJson == null) { + failedIds.add(id); + continue; + } + + try { + final data = json.decode(deviceJson) as Map; + final deviceType = _determineDeviceType(data); + + switch (deviceType) { + case DeviceModelSealed.typeAccessPoint: + apDevices.add(_parseAsAP(data)); + case DeviceModelSealed.typeONT: + ontDevices.add(_parseAsONT(data)); + case DeviceModelSealed.typeSwitch: + switchDevices.add(_parseAsSwitch(data)); + case DeviceModelSealed.typeWLAN: + wlanDevices.add(_parseAsWLAN(data)); + default: + _logger.w('Unknown device type for id $id: $deviceType'); + failedIds.add(id); + } + } on Exception catch (e) { + _logger.w('Failed to parse device $id: $e'); + failedIds.add(id); + } + } + + // Cache to new typed data sources + if (apDevices.isNotEmpty) { + await apDataSource.cacheDevices(apDevices); + await apDataSource.flushNow(); + } + if (ontDevices.isNotEmpty) { + await ontDataSource.cacheDevices(ontDevices); + await ontDataSource.flushNow(); + } + if (switchDevices.isNotEmpty) { + await switchDataSource.cacheDevices(switchDevices); + await switchDataSource.flushNow(); + } + if (wlanDevices.isNotEmpty) { + await wlanDataSource.cacheDevices(wlanDevices); + await wlanDataSource.flushNow(); + } + + // Clean up old cache + await _cleanupOldCache(index); + + // Mark migration complete + await storageService.setString(_migrationCompleteKey, 'true'); + + final total = apDevices.length + ontDevices.length + + switchDevices.length + wlanDevices.length; + + _logger.i('Migration complete: $total devices migrated ' + '(AP: ${apDevices.length}, ONT: ${ontDevices.length}, ' + 'Switch: ${switchDevices.length}, WLAN: ${wlanDevices.length})'); + + return MigrationResult( + success: true, + migrated: true, + message: 'Migrated $total devices', + apCount: apDevices.length, + ontCount: ontDevices.length, + switchCount: switchDevices.length, + wlanCount: wlanDevices.length, + failedCount: failedIds.length, + ); + } on Exception catch (e, stack) { + _logger.e('Migration failed: $e', error: e, stackTrace: stack); + return MigrationResult( + success: false, + migrated: false, + message: 'Migration failed: $e', + ); + } + } + + /// Determine device type from old cache data + String? _determineDeviceType(Map data) { + // Check explicit type field + final type = data['type']?.toString(); + if (type != null && DeviceModelSealed.allTypes.contains(type)) { + return type; + } + + // Check device_type field (used by Freezed) + final deviceType = data['device_type']?.toString(); + if (deviceType != null && DeviceModelSealed.allTypes.contains(deviceType)) { + return deviceType; + } + + return null; + } + + APModel _parseAsAP(Map data) { + // Ensure device_type is set for Freezed + final normalized = Map.from(data); + normalized['device_type'] = DeviceModelSealed.typeAccessPoint; + return APModel.fromJson(normalized); + } + + ONTModel _parseAsONT(Map data) { + final normalized = Map.from(data); + normalized['device_type'] = DeviceModelSealed.typeONT; + return ONTModel.fromJson(normalized); + } + + SwitchModel _parseAsSwitch(Map data) { + final normalized = Map.from(data); + normalized['device_type'] = DeviceModelSealed.typeSwitch; + return SwitchModel.fromJson(normalized); + } + + WLANModel _parseAsWLAN(Map data) { + final normalized = Map.from(data); + normalized['device_type'] = DeviceModelSealed.typeWLAN; + return WLANModel.fromJson(normalized); + } + + /// Clean up old cache keys + Future _cleanupOldCache(List deviceIds) async { + // Remove old index + await storageService.remove(_oldIndexKey); + await storageService.remove(_oldTimestampKey); + + // Remove individual device entries + for (final id in deviceIds) { + await storageService.remove('$_oldDeviceKeyPrefix$id'); + } + + _logger.d('Cleaned up ${deviceIds.length + 2} old cache keys'); + } + + /// Reset migration state (for testing) + Future resetMigration() async { + await storageService.remove(_migrationCompleteKey); + } +} + +/// Result of a migration attempt +class MigrationResult { + const MigrationResult({ + required this.success, + required this.migrated, + required this.message, + this.apCount = 0, + this.ontCount = 0, + this.switchCount = 0, + this.wlanCount = 0, + this.failedCount = 0, + }); + + final bool success; + final bool migrated; + final String message; + final int apCount; + final int ontCount; + final int switchCount; + final int wlanCount; + final int failedCount; + + int get totalMigrated => apCount + ontCount + switchCount + wlanCount; +} diff --git a/lib/features/devices/data/models/device_model_sealed.freezed.dart b/lib/features/devices/data/models/device_model_sealed.freezed.dart index aa1e983..48d360f 100644 --- a/lib/features/devices/data/models/device_model_sealed.freezed.dart +++ b/lib/features/devices/data/models/device_model_sealed.freezed.dart @@ -55,8 +55,6 @@ mixin _$DeviceModelSealed { String? get firmware => throw _privateConstructorUsedError; String? get note => throw _privateConstructorUsedError; List? get images => throw _privateConstructorUsedError; - @JsonKey(name: 'image_signed_ids') - List? get imageSignedIds => throw _privateConstructorUsedError; @JsonKey(name: 'health_notices') List? get healthNotices => throw _privateConstructorUsedError; @@ -80,7 +78,6 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -93,7 +90,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus) + Map? onboardingStatus) ap, required TResult Function( String id, @@ -111,14 +108,13 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase) @@ -139,7 +135,6 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -168,7 +163,6 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -201,7 +195,6 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -214,7 +207,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus)? + Map? onboardingStatus)? ap, TResult? Function( String id, @@ -232,14 +225,13 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -260,7 +252,6 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -289,7 +280,6 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -322,7 +312,6 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -335,7 +324,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus)? + Map? onboardingStatus)? ap, TResult Function( String id, @@ -353,14 +342,13 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -381,7 +369,6 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -410,7 +397,6 @@ mixin _$DeviceModelSealed { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -479,7 +465,6 @@ abstract class $DeviceModelSealedCopyWith<$Res> { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts}); @@ -515,7 +500,6 @@ class _$DeviceModelSealedCopyWithImpl<$Res, $Val extends DeviceModelSealed> Object? firmware = freezed, Object? note = freezed, Object? images = freezed, - Object? imageSignedIds = freezed, Object? healthNotices = freezed, Object? hnCounts = freezed, }) { @@ -580,10 +564,6 @@ class _$DeviceModelSealedCopyWithImpl<$Res, $Val extends DeviceModelSealed> ? _value.images : images // ignore: cast_nullable_to_non_nullable as List?, - imageSignedIds: freezed == imageSignedIds - ? _value.imageSignedIds - : imageSignedIds // ignore: cast_nullable_to_non_nullable - as List?, healthNotices: freezed == healthNotices ? _value.healthNotices : healthNotices // ignore: cast_nullable_to_non_nullable @@ -644,7 +624,6 @@ abstract class _$$APModelImplCopyWith<$Res> String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'connection_state') String? connectionState, @@ -656,13 +635,12 @@ abstract class _$$APModelImplCopyWith<$Res> @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus}); + Map? onboardingStatus}); @override $RoomModelCopyWith<$Res>? get pmsRoom; @override $HealthCountsModelCopyWith<$Res>? get hnCounts; - $OnboardingStatusPayloadCopyWith<$Res>? get onboardingStatus; } /// @nodoc @@ -691,7 +669,6 @@ class __$$APModelImplCopyWithImpl<$Res> Object? firmware = freezed, Object? note = freezed, Object? images = freezed, - Object? imageSignedIds = freezed, Object? healthNotices = freezed, Object? hnCounts = freezed, Object? connectionState = freezed, @@ -765,10 +742,6 @@ class __$$APModelImplCopyWithImpl<$Res> ? _value._images : images // ignore: cast_nullable_to_non_nullable as List?, - imageSignedIds: freezed == imageSignedIds - ? _value._imageSignedIds - : imageSignedIds // ignore: cast_nullable_to_non_nullable - as List?, healthNotices: freezed == healthNotices ? _value._healthNotices : healthNotices // ignore: cast_nullable_to_non_nullable @@ -810,24 +783,11 @@ class __$$APModelImplCopyWithImpl<$Res> : currentDownload // ignore: cast_nullable_to_non_nullable as double?, onboardingStatus: freezed == onboardingStatus - ? _value.onboardingStatus + ? _value._onboardingStatus : onboardingStatus // ignore: cast_nullable_to_non_nullable - as OnboardingStatusPayload?, + as Map?, )); } - - @override - @pragma('vm:prefer-inline') - $OnboardingStatusPayloadCopyWith<$Res>? get onboardingStatus { - if (_value.onboardingStatus == null) { - return null; - } - - return $OnboardingStatusPayloadCopyWith<$Res>(_value.onboardingStatus!, - (value) { - return _then(_value.copyWith(onboardingStatus: value)); - }); - } } /// @nodoc @@ -849,7 +809,6 @@ class _$APModelImpl extends APModel { this.firmware, this.note, final List? images, - @JsonKey(name: 'image_signed_ids') final List? imageSignedIds, @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') this.hnCounts, @@ -861,12 +820,13 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'max_clients') this.maxClients, @JsonKey(name: 'current_upload') this.currentUpload, @JsonKey(name: 'current_download') this.currentDownload, - @JsonKey(name: 'ap_onboarding_status') this.onboardingStatus, + @JsonKey(name: 'ap_onboarding_status') + final Map? onboardingStatus, final String? $type}) : _metadata = metadata, _images = images, - _imageSignedIds = imageSignedIds, _healthNotices = healthNotices, + _onboardingStatus = onboardingStatus, $type = $type ?? 'access_point', super._(); @@ -926,17 +886,6 @@ class _$APModelImpl extends APModel { return EqualUnmodifiableListView(value); } - final List? _imageSignedIds; - @override - @JsonKey(name: 'image_signed_ids') - List? get imageSignedIds { - final value = _imageSignedIds; - if (value == null) return null; - if (_imageSignedIds is EqualUnmodifiableListView) return _imageSignedIds; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - final List? _healthNotices; @override @JsonKey(name: 'health_notices') @@ -974,16 +923,23 @@ class _$APModelImpl extends APModel { @override @JsonKey(name: 'current_download') final double? currentDownload; + final Map? _onboardingStatus; @override @JsonKey(name: 'ap_onboarding_status') - final OnboardingStatusPayload? onboardingStatus; + Map? get onboardingStatus { + final value = _onboardingStatus; + if (value == null) return null; + if (_onboardingStatus is EqualUnmodifiableMapView) return _onboardingStatus; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } @JsonKey(name: 'device_type') final String $type; @override String toString() { - return 'DeviceModelSealed.ap(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, imageSignedIds: $imageSignedIds, healthNotices: $healthNotices, hnCounts: $hnCounts, connectionState: $connectionState, signalStrength: $signalStrength, connectedClients: $connectedClients, ssid: $ssid, channel: $channel, maxClients: $maxClients, currentUpload: $currentUpload, currentDownload: $currentDownload, onboardingStatus: $onboardingStatus)'; + return 'DeviceModelSealed.ap(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, healthNotices: $healthNotices, hnCounts: $hnCounts, connectionState: $connectionState, signalStrength: $signalStrength, connectedClients: $connectedClients, ssid: $ssid, channel: $channel, maxClients: $maxClients, currentUpload: $currentUpload, currentDownload: $currentDownload, onboardingStatus: $onboardingStatus)'; } @override @@ -1013,8 +969,6 @@ class _$APModelImpl extends APModel { other.firmware == firmware) && (identical(other.note, note) || other.note == note) && const DeepCollectionEquality().equals(other._images, _images) && - const DeepCollectionEquality() - .equals(other._imageSignedIds, _imageSignedIds) && const DeepCollectionEquality() .equals(other._healthNotices, _healthNotices) && (identical(other.hnCounts, hnCounts) || @@ -1033,8 +987,8 @@ class _$APModelImpl extends APModel { other.currentUpload == currentUpload) && (identical(other.currentDownload, currentDownload) || other.currentDownload == currentDownload) && - (identical(other.onboardingStatus, onboardingStatus) || - other.onboardingStatus == onboardingStatus)); + const DeepCollectionEquality() + .equals(other._onboardingStatus, _onboardingStatus)); } @JsonKey(ignore: true) @@ -1056,7 +1010,6 @@ class _$APModelImpl extends APModel { firmware, note, const DeepCollectionEquality().hash(_images), - const DeepCollectionEquality().hash(_imageSignedIds), const DeepCollectionEquality().hash(_healthNotices), hnCounts, connectionState, @@ -1067,7 +1020,7 @@ class _$APModelImpl extends APModel { maxClients, currentUpload, currentDownload, - onboardingStatus + const DeepCollectionEquality().hash(_onboardingStatus) ]); @JsonKey(ignore: true) @@ -1095,7 +1048,6 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -1108,7 +1060,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus) + Map? onboardingStatus) ap, required TResult Function( String id, @@ -1126,14 +1078,13 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase) @@ -1154,7 +1105,6 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -1183,7 +1133,6 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -1213,7 +1162,6 @@ class _$APModelImpl extends APModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, connectionState, @@ -1246,7 +1194,6 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -1259,7 +1206,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus)? + Map? onboardingStatus)? ap, TResult? Function( String id, @@ -1277,14 +1224,13 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -1305,7 +1251,6 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -1334,7 +1279,6 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -1364,7 +1308,6 @@ class _$APModelImpl extends APModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, connectionState, @@ -1397,7 +1340,6 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -1410,7 +1352,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus)? + Map? onboardingStatus)? ap, TResult Function( String id, @@ -1428,14 +1370,13 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -1456,7 +1397,6 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -1485,7 +1425,6 @@ class _$APModelImpl extends APModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -1517,7 +1456,6 @@ class _$APModelImpl extends APModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, connectionState, @@ -1595,7 +1533,6 @@ abstract class APModel extends DeviceModelSealed { final String? firmware, final String? note, final List? images, - @JsonKey(name: 'image_signed_ids') final List? imageSignedIds, @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts, @@ -1608,7 +1545,7 @@ abstract class APModel extends DeviceModelSealed { @JsonKey(name: 'current_upload') final double? currentUpload, @JsonKey(name: 'current_download') final double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - final OnboardingStatusPayload? onboardingStatus}) = _$APModelImpl; + final Map? onboardingStatus}) = _$APModelImpl; const APModel._() : super._(); factory APModel.fromJson(Map json) = _$APModelImpl.fromJson; @@ -1650,9 +1587,6 @@ abstract class APModel extends DeviceModelSealed { @override List? get images; @override - @JsonKey(name: 'image_signed_ids') - List? get imageSignedIds; - @override @JsonKey(name: 'health_notices') List? get healthNotices; @override @@ -1673,7 +1607,7 @@ abstract class APModel extends DeviceModelSealed { @JsonKey(name: 'current_download') double? get currentDownload; @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? get onboardingStatus; + Map? get onboardingStatus; @override @JsonKey(ignore: true) _$$APModelImplCopyWith<_$APModelImpl> get copyWith => @@ -1704,13 +1638,12 @@ abstract class _$$ONTModelImplCopyWith<$Res> String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase}); @@ -1719,7 +1652,6 @@ abstract class _$$ONTModelImplCopyWith<$Res> $RoomModelCopyWith<$Res>? get pmsRoom; @override $HealthCountsModelCopyWith<$Res>? get hnCounts; - $OnboardingStatusPayloadCopyWith<$Res>? get onboardingStatus; } /// @nodoc @@ -1748,7 +1680,6 @@ class __$$ONTModelImplCopyWithImpl<$Res> Object? firmware = freezed, Object? note = freezed, Object? images = freezed, - Object? imageSignedIds = freezed, Object? healthNotices = freezed, Object? hnCounts = freezed, Object? isRegistered = freezed, @@ -1819,10 +1750,6 @@ class __$$ONTModelImplCopyWithImpl<$Res> ? _value._images : images // ignore: cast_nullable_to_non_nullable as List?, - imageSignedIds: freezed == imageSignedIds - ? _value._imageSignedIds - : imageSignedIds // ignore: cast_nullable_to_non_nullable - as List?, healthNotices: freezed == healthNotices ? _value._healthNotices : healthNotices // ignore: cast_nullable_to_non_nullable @@ -1840,9 +1767,9 @@ class __$$ONTModelImplCopyWithImpl<$Res> : switchPort // ignore: cast_nullable_to_non_nullable as Map?, onboardingStatus: freezed == onboardingStatus - ? _value.onboardingStatus + ? _value._onboardingStatus : onboardingStatus // ignore: cast_nullable_to_non_nullable - as OnboardingStatusPayload?, + as Map?, ports: freezed == ports ? _value._ports : ports // ignore: cast_nullable_to_non_nullable @@ -1857,19 +1784,6 @@ class __$$ONTModelImplCopyWithImpl<$Res> as String?, )); } - - @override - @pragma('vm:prefer-inline') - $OnboardingStatusPayloadCopyWith<$Res>? get onboardingStatus { - if (_value.onboardingStatus == null) { - return null; - } - - return $OnboardingStatusPayloadCopyWith<$Res>(_value.onboardingStatus!, - (value) { - return _then(_value.copyWith(onboardingStatus: value)); - }); - } } /// @nodoc @@ -1891,22 +1805,22 @@ class _$ONTModelImpl extends ONTModel { this.firmware, this.note, final List? images, - @JsonKey(name: 'image_signed_ids') final List? imageSignedIds, @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') this.hnCounts, @JsonKey(name: 'is_registered') this.isRegistered, @JsonKey(name: 'switch_port') final Map? switchPort, - @JsonKey(name: 'ont_onboarding_status') this.onboardingStatus, + @JsonKey(name: 'ont_onboarding_status') + final Map? onboardingStatus, @JsonKey(name: 'ont_ports') final List>? ports, this.uptime, this.phase, final String? $type}) : _metadata = metadata, _images = images, - _imageSignedIds = imageSignedIds, _healthNotices = healthNotices, _switchPort = switchPort, + _onboardingStatus = onboardingStatus, _ports = ports, $type = $type ?? 'ont', super._(); @@ -1967,17 +1881,6 @@ class _$ONTModelImpl extends ONTModel { return EqualUnmodifiableListView(value); } - final List? _imageSignedIds; - @override - @JsonKey(name: 'image_signed_ids') - List? get imageSignedIds { - final value = _imageSignedIds; - if (value == null) return null; - if (_imageSignedIds is EqualUnmodifiableListView) return _imageSignedIds; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - final List? _healthNotices; @override @JsonKey(name: 'health_notices') @@ -2007,9 +1910,17 @@ class _$ONTModelImpl extends ONTModel { return EqualUnmodifiableMapView(value); } + final Map? _onboardingStatus; @override @JsonKey(name: 'ont_onboarding_status') - final OnboardingStatusPayload? onboardingStatus; + Map? get onboardingStatus { + final value = _onboardingStatus; + if (value == null) return null; + if (_onboardingStatus is EqualUnmodifiableMapView) return _onboardingStatus; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + final List>? _ports; @override @JsonKey(name: 'ont_ports') @@ -2031,7 +1942,7 @@ class _$ONTModelImpl extends ONTModel { @override String toString() { - return 'DeviceModelSealed.ont(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, imageSignedIds: $imageSignedIds, healthNotices: $healthNotices, hnCounts: $hnCounts, isRegistered: $isRegistered, switchPort: $switchPort, onboardingStatus: $onboardingStatus, ports: $ports, uptime: $uptime, phase: $phase)'; + return 'DeviceModelSealed.ont(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, healthNotices: $healthNotices, hnCounts: $hnCounts, isRegistered: $isRegistered, switchPort: $switchPort, onboardingStatus: $onboardingStatus, ports: $ports, uptime: $uptime, phase: $phase)'; } @override @@ -2061,8 +1972,6 @@ class _$ONTModelImpl extends ONTModel { other.firmware == firmware) && (identical(other.note, note) || other.note == note) && const DeepCollectionEquality().equals(other._images, _images) && - const DeepCollectionEquality() - .equals(other._imageSignedIds, _imageSignedIds) && const DeepCollectionEquality() .equals(other._healthNotices, _healthNotices) && (identical(other.hnCounts, hnCounts) || @@ -2071,8 +1980,8 @@ class _$ONTModelImpl extends ONTModel { other.isRegistered == isRegistered) && const DeepCollectionEquality() .equals(other._switchPort, _switchPort) && - (identical(other.onboardingStatus, onboardingStatus) || - other.onboardingStatus == onboardingStatus) && + const DeepCollectionEquality() + .equals(other._onboardingStatus, _onboardingStatus) && const DeepCollectionEquality().equals(other._ports, _ports) && (identical(other.uptime, uptime) || other.uptime == uptime) && (identical(other.phase, phase) || other.phase == phase)); @@ -2097,12 +2006,11 @@ class _$ONTModelImpl extends ONTModel { firmware, note, const DeepCollectionEquality().hash(_images), - const DeepCollectionEquality().hash(_imageSignedIds), const DeepCollectionEquality().hash(_healthNotices), hnCounts, isRegistered, const DeepCollectionEquality().hash(_switchPort), - onboardingStatus, + const DeepCollectionEquality().hash(_onboardingStatus), const DeepCollectionEquality().hash(_ports), uptime, phase @@ -2133,7 +2041,6 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -2146,7 +2053,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus) + Map? onboardingStatus) ap, required TResult Function( String id, @@ -2164,14 +2071,13 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase) @@ -2192,7 +2098,6 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -2221,7 +2126,6 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -2251,7 +2155,6 @@ class _$ONTModelImpl extends ONTModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, isRegistered, @@ -2281,7 +2184,6 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -2294,7 +2196,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus)? + Map? onboardingStatus)? ap, TResult? Function( String id, @@ -2312,14 +2214,13 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -2340,7 +2241,6 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -2369,7 +2269,6 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -2399,7 +2298,6 @@ class _$ONTModelImpl extends ONTModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, isRegistered, @@ -2429,7 +2327,6 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -2442,7 +2339,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus)? + Map? onboardingStatus)? ap, TResult Function( String id, @@ -2460,14 +2357,13 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -2488,7 +2384,6 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -2517,7 +2412,6 @@ class _$ONTModelImpl extends ONTModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -2549,7 +2443,6 @@ class _$ONTModelImpl extends ONTModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, isRegistered, @@ -2624,14 +2517,13 @@ abstract class ONTModel extends DeviceModelSealed { final String? firmware, final String? note, final List? images, - @JsonKey(name: 'image_signed_ids') final List? imageSignedIds, @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') final bool? isRegistered, @JsonKey(name: 'switch_port') final Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - final OnboardingStatusPayload? onboardingStatus, + final Map? onboardingStatus, @JsonKey(name: 'ont_ports') final List>? ports, final String? uptime, final String? phase}) = _$ONTModelImpl; @@ -2677,9 +2569,6 @@ abstract class ONTModel extends DeviceModelSealed { @override List? get images; @override - @JsonKey(name: 'image_signed_ids') - List? get imageSignedIds; - @override @JsonKey(name: 'health_notices') List? get healthNotices; @override @@ -2690,7 +2579,7 @@ abstract class ONTModel extends DeviceModelSealed { @JsonKey(name: 'switch_port') Map? get switchPort; @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? get onboardingStatus; + Map? get onboardingStatus; @JsonKey(name: 'ont_ports') List>? get ports; String? get uptime; @@ -2725,7 +2614,6 @@ abstract class _$$SwitchModelImplCopyWith<$Res> String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, String? host, @@ -2769,7 +2657,6 @@ class __$$SwitchModelImplCopyWithImpl<$Res> Object? firmware = freezed, Object? note = freezed, Object? images = freezed, - Object? imageSignedIds = freezed, Object? healthNotices = freezed, Object? hnCounts = freezed, Object? host = freezed, @@ -2841,10 +2728,6 @@ class __$$SwitchModelImplCopyWithImpl<$Res> ? _value._images : images // ignore: cast_nullable_to_non_nullable as List?, - imageSignedIds: freezed == imageSignedIds - ? _value._imageSignedIds - : imageSignedIds // ignore: cast_nullable_to_non_nullable - as List?, healthNotices: freezed == healthNotices ? _value._healthNotices : healthNotices // ignore: cast_nullable_to_non_nullable @@ -2904,7 +2787,6 @@ class _$SwitchModelImpl extends SwitchModel { this.firmware, this.note, final List? images, - @JsonKey(name: 'image_signed_ids') final List? imageSignedIds, @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') this.hnCounts, @@ -2918,7 +2800,6 @@ class _$SwitchModelImpl extends SwitchModel { final String? $type}) : _metadata = metadata, _images = images, - _imageSignedIds = imageSignedIds, _healthNotices = healthNotices, _ports = ports, $type = $type ?? 'switch', @@ -2980,17 +2861,6 @@ class _$SwitchModelImpl extends SwitchModel { return EqualUnmodifiableListView(value); } - final List? _imageSignedIds; - @override - @JsonKey(name: 'image_signed_ids') - List? get imageSignedIds { - final value = _imageSignedIds; - if (value == null) return null; - if (_imageSignedIds is EqualUnmodifiableListView) return _imageSignedIds; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - final List? _healthNotices; @override @JsonKey(name: 'health_notices') @@ -3039,7 +2909,7 @@ class _$SwitchModelImpl extends SwitchModel { @override String toString() { - return 'DeviceModelSealed.switchDevice(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, imageSignedIds: $imageSignedIds, healthNotices: $healthNotices, hnCounts: $hnCounts, host: $host, ports: $ports, lastConfigSync: $lastConfigSync, lastConfigSyncAttempt: $lastConfigSyncAttempt, cpuUsage: $cpuUsage, memoryUsage: $memoryUsage, temperature: $temperature)'; + return 'DeviceModelSealed.switchDevice(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, healthNotices: $healthNotices, hnCounts: $hnCounts, host: $host, ports: $ports, lastConfigSync: $lastConfigSync, lastConfigSyncAttempt: $lastConfigSyncAttempt, cpuUsage: $cpuUsage, memoryUsage: $memoryUsage, temperature: $temperature)'; } @override @@ -3069,8 +2939,6 @@ class _$SwitchModelImpl extends SwitchModel { other.firmware == firmware) && (identical(other.note, note) || other.note == note) && const DeepCollectionEquality().equals(other._images, _images) && - const DeepCollectionEquality() - .equals(other._imageSignedIds, _imageSignedIds) && const DeepCollectionEquality() .equals(other._healthNotices, _healthNotices) && (identical(other.hnCounts, hnCounts) || @@ -3108,7 +2976,6 @@ class _$SwitchModelImpl extends SwitchModel { firmware, note, const DeepCollectionEquality().hash(_images), - const DeepCollectionEquality().hash(_imageSignedIds), const DeepCollectionEquality().hash(_healthNotices), hnCounts, host, @@ -3145,7 +3012,6 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -3158,7 +3024,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus) + Map? onboardingStatus) ap, required TResult Function( String id, @@ -3176,14 +3042,13 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase) @@ -3204,7 +3069,6 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -3233,7 +3097,6 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -3263,7 +3126,6 @@ class _$SwitchModelImpl extends SwitchModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, host, @@ -3294,7 +3156,6 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -3307,7 +3168,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus)? + Map? onboardingStatus)? ap, TResult? Function( String id, @@ -3325,14 +3186,13 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -3353,7 +3213,6 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -3382,7 +3241,6 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -3412,7 +3270,6 @@ class _$SwitchModelImpl extends SwitchModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, host, @@ -3443,7 +3300,6 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -3456,7 +3312,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus)? + Map? onboardingStatus)? ap, TResult Function( String id, @@ -3474,14 +3330,13 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -3502,7 +3357,6 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -3531,7 +3385,6 @@ class _$SwitchModelImpl extends SwitchModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -3563,7 +3416,6 @@ class _$SwitchModelImpl extends SwitchModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, host, @@ -3639,7 +3491,6 @@ abstract class SwitchModel extends DeviceModelSealed { final String? firmware, final String? note, final List? images, - @JsonKey(name: 'image_signed_ids') final List? imageSignedIds, @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts, @@ -3693,9 +3544,6 @@ abstract class SwitchModel extends DeviceModelSealed { @override List? get images; @override - @JsonKey(name: 'image_signed_ids') - List? get imageSignedIds; - @override @JsonKey(name: 'health_notices') List? get healthNotices; @override @@ -3743,7 +3591,6 @@ abstract class _$$WLANModelImplCopyWith<$Res> String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'controller_type') String? controllerType, @@ -3787,7 +3634,6 @@ class __$$WLANModelImplCopyWithImpl<$Res> Object? firmware = freezed, Object? note = freezed, Object? images = freezed, - Object? imageSignedIds = freezed, Object? healthNotices = freezed, Object? hnCounts = freezed, Object? controllerType = freezed, @@ -3860,10 +3706,6 @@ class __$$WLANModelImplCopyWithImpl<$Res> ? _value._images : images // ignore: cast_nullable_to_non_nullable as List?, - imageSignedIds: freezed == imageSignedIds - ? _value._imageSignedIds - : imageSignedIds // ignore: cast_nullable_to_non_nullable - as List?, healthNotices: freezed == healthNotices ? _value._healthNotices : healthNotices // ignore: cast_nullable_to_non_nullable @@ -3927,7 +3769,6 @@ class _$WLANModelImpl extends WLANModel { this.firmware, this.note, final List? images, - @JsonKey(name: 'image_signed_ids') final List? imageSignedIds, @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') this.hnCounts, @@ -3942,7 +3783,6 @@ class _$WLANModelImpl extends WLANModel { final String? $type}) : _metadata = metadata, _images = images, - _imageSignedIds = imageSignedIds, _healthNotices = healthNotices, $type = $type ?? 'wlan_controller', super._(); @@ -4003,17 +3843,6 @@ class _$WLANModelImpl extends WLANModel { return EqualUnmodifiableListView(value); } - final List? _imageSignedIds; - @override - @JsonKey(name: 'image_signed_ids') - List? get imageSignedIds { - final value = _imageSignedIds; - if (value == null) return null; - if (_imageSignedIds is EqualUnmodifiableListView) return _imageSignedIds; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - final List? _healthNotices; @override @JsonKey(name: 'health_notices') @@ -4057,7 +3886,7 @@ class _$WLANModelImpl extends WLANModel { @override String toString() { - return 'DeviceModelSealed.wlan(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, imageSignedIds: $imageSignedIds, healthNotices: $healthNotices, hnCounts: $hnCounts, controllerType: $controllerType, managedAPs: $managedAPs, vlan: $vlan, totalUpload: $totalUpload, totalDownload: $totalDownload, packetLoss: $packetLoss, latency: $latency, restartCount: $restartCount)'; + return 'DeviceModelSealed.wlan(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, healthNotices: $healthNotices, hnCounts: $hnCounts, controllerType: $controllerType, managedAPs: $managedAPs, vlan: $vlan, totalUpload: $totalUpload, totalDownload: $totalDownload, packetLoss: $packetLoss, latency: $latency, restartCount: $restartCount)'; } @override @@ -4087,8 +3916,6 @@ class _$WLANModelImpl extends WLANModel { other.firmware == firmware) && (identical(other.note, note) || other.note == note) && const DeepCollectionEquality().equals(other._images, _images) && - const DeepCollectionEquality() - .equals(other._imageSignedIds, _imageSignedIds) && const DeepCollectionEquality() .equals(other._healthNotices, _healthNotices) && (identical(other.hnCounts, hnCounts) || @@ -4128,7 +3955,6 @@ class _$WLANModelImpl extends WLANModel { firmware, note, const DeepCollectionEquality().hash(_images), - const DeepCollectionEquality().hash(_imageSignedIds), const DeepCollectionEquality().hash(_healthNotices), hnCounts, controllerType, @@ -4166,7 +3992,6 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -4179,7 +4004,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus) + Map? onboardingStatus) ap, required TResult Function( String id, @@ -4197,14 +4022,13 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase) @@ -4225,7 +4049,6 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -4254,7 +4077,6 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -4284,7 +4106,6 @@ class _$WLANModelImpl extends WLANModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, controllerType, @@ -4316,7 +4137,6 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -4329,7 +4149,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus)? + Map? onboardingStatus)? ap, TResult? Function( String id, @@ -4347,14 +4167,13 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -4375,7 +4194,6 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -4404,7 +4222,6 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -4434,7 +4251,6 @@ class _$WLANModelImpl extends WLANModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, controllerType, @@ -4466,7 +4282,6 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -4479,7 +4294,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - OnboardingStatusPayload? onboardingStatus)? + Map? onboardingStatus)? ap, TResult Function( String id, @@ -4497,14 +4312,13 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - OnboardingStatusPayload? onboardingStatus, + Map? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -4525,7 +4339,6 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -4554,7 +4367,6 @@ class _$WLANModelImpl extends WLANModel { String? firmware, String? note, List? images, - @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, @@ -4586,7 +4398,6 @@ class _$WLANModelImpl extends WLANModel { firmware, note, images, - imageSignedIds, healthNotices, hnCounts, controllerType, @@ -4663,7 +4474,6 @@ abstract class WLANModel extends DeviceModelSealed { final String? firmware, final String? note, final List? images, - @JsonKey(name: 'image_signed_ids') final List? imageSignedIds, @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts, @@ -4718,9 +4528,6 @@ abstract class WLANModel extends DeviceModelSealed { @override List? get images; @override - @JsonKey(name: 'image_signed_ids') - List? get imageSignedIds; - @override @JsonKey(name: 'health_notices') List? get healthNotices; @override diff --git a/lib/features/devices/data/models/device_model_sealed.g.dart b/lib/features/devices/data/models/device_model_sealed.g.dart index 161985e..9ada501 100644 --- a/lib/features/devices/data/models/device_model_sealed.g.dart +++ b/lib/features/devices/data/models/device_model_sealed.g.dart @@ -28,9 +28,6 @@ _$APModelImpl _$$APModelImplFromJson(Map json) => note: json['note'] as String?, images: (json['images'] as List?)?.map((e) => e as String).toList(), - imageSignedIds: (json['image_signed_ids'] as List?) - ?.map((e) => e as String) - .toList(), healthNotices: (json['health_notices'] as List?) ?.map((e) => HealthNoticeModel.fromJson(e as Map)) .toList(), @@ -46,10 +43,7 @@ _$APModelImpl _$$APModelImplFromJson(Map json) => maxClients: (json['max_clients'] as num?)?.toInt(), currentUpload: (json['current_upload'] as num?)?.toDouble(), currentDownload: (json['current_download'] as num?)?.toDouble(), - onboardingStatus: json['ap_onboarding_status'] == null - ? null - : OnboardingStatusPayload.fromJson( - json['ap_onboarding_status'] as Map), + onboardingStatus: json['ap_onboarding_status'] as Map?, $type: json['device_type'] as String?, ); @@ -78,7 +72,6 @@ Map _$$APModelImplToJson(_$APModelImpl instance) { writeNotNull('firmware', instance.firmware); writeNotNull('note', instance.note); writeNotNull('images', instance.images); - writeNotNull('image_signed_ids', instance.imageSignedIds); writeNotNull('health_notices', instance.healthNotices?.map((e) => e.toJson()).toList()); writeNotNull('hn_counts', instance.hnCounts?.toJson()); @@ -90,7 +83,7 @@ Map _$$APModelImplToJson(_$APModelImpl instance) { writeNotNull('max_clients', instance.maxClients); writeNotNull('current_upload', instance.currentUpload); writeNotNull('current_download', instance.currentDownload); - writeNotNull('ap_onboarding_status', instance.onboardingStatus?.toJson()); + writeNotNull('ap_onboarding_status', instance.onboardingStatus); val['device_type'] = instance.$type; return val; } @@ -117,9 +110,6 @@ _$ONTModelImpl _$$ONTModelImplFromJson(Map json) => note: json['note'] as String?, images: (json['images'] as List?)?.map((e) => e as String).toList(), - imageSignedIds: (json['image_signed_ids'] as List?) - ?.map((e) => e as String) - .toList(), healthNotices: (json['health_notices'] as List?) ?.map((e) => HealthNoticeModel.fromJson(e as Map)) .toList(), @@ -129,10 +119,7 @@ _$ONTModelImpl _$$ONTModelImplFromJson(Map json) => json['hn_counts'] as Map), isRegistered: json['is_registered'] as bool?, switchPort: json['switch_port'] as Map?, - onboardingStatus: json['ont_onboarding_status'] == null - ? null - : OnboardingStatusPayload.fromJson( - json['ont_onboarding_status'] as Map), + onboardingStatus: json['ont_onboarding_status'] as Map?, ports: (json['ont_ports'] as List?) ?.map((e) => e as Map) .toList(), @@ -166,13 +153,12 @@ Map _$$ONTModelImplToJson(_$ONTModelImpl instance) { writeNotNull('firmware', instance.firmware); writeNotNull('note', instance.note); writeNotNull('images', instance.images); - writeNotNull('image_signed_ids', instance.imageSignedIds); writeNotNull('health_notices', instance.healthNotices?.map((e) => e.toJson()).toList()); writeNotNull('hn_counts', instance.hnCounts?.toJson()); writeNotNull('is_registered', instance.isRegistered); writeNotNull('switch_port', instance.switchPort); - writeNotNull('ont_onboarding_status', instance.onboardingStatus?.toJson()); + writeNotNull('ont_onboarding_status', instance.onboardingStatus); writeNotNull('ont_ports', instance.ports); writeNotNull('uptime', instance.uptime); writeNotNull('phase', instance.phase); @@ -202,9 +188,6 @@ _$SwitchModelImpl _$$SwitchModelImplFromJson(Map json) => note: json['note'] as String?, images: (json['images'] as List?)?.map((e) => e as String).toList(), - imageSignedIds: (json['image_signed_ids'] as List?) - ?.map((e) => e as String) - .toList(), healthNotices: (json['health_notices'] as List?) ?.map((e) => HealthNoticeModel.fromJson(e as Map)) .toList(), @@ -253,7 +236,6 @@ Map _$$SwitchModelImplToJson(_$SwitchModelImpl instance) { writeNotNull('firmware', instance.firmware); writeNotNull('note', instance.note); writeNotNull('images', instance.images); - writeNotNull('image_signed_ids', instance.imageSignedIds); writeNotNull('health_notices', instance.healthNotices?.map((e) => e.toJson()).toList()); writeNotNull('hn_counts', instance.hnCounts?.toJson()); @@ -292,9 +274,6 @@ _$WLANModelImpl _$$WLANModelImplFromJson(Map json) => note: json['note'] as String?, images: (json['images'] as List?)?.map((e) => e as String).toList(), - imageSignedIds: (json['image_signed_ids'] as List?) - ?.map((e) => e as String) - .toList(), healthNotices: (json['health_notices'] as List?) ?.map((e) => HealthNoticeModel.fromJson(e as Map)) .toList(), @@ -338,7 +317,6 @@ Map _$$WLANModelImplToJson(_$WLANModelImpl instance) { writeNotNull('firmware', instance.firmware); writeNotNull('note', instance.note); writeNotNull('images', instance.images); - writeNotNull('image_signed_ids', instance.imageSignedIds); writeNotNull('health_notices', instance.healthNotices?.map((e) => e.toJson()).toList()); writeNotNull('hn_counts', instance.hnCounts?.toJson()); From cac9d5fca7b46eb724e2286d0f7c3a8a451ef47e Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Mon, 19 Jan 2026 18:04:51 -0800 Subject: [PATCH 02/24] Speed test implmentation with a service, does a join table for speed test and speed test results --- docs/IPERF3_IMPLEMENTATION.md | 1527 +++++++++++++++++ .../datasources/speed_test_data_source.dart | 36 + .../speed_test_websocket_data_source.dart | 262 +++ .../speed_test_repository_impl.dart | 224 +++ .../data/services/speed_test_service.dart | 16 +- .../domain/entities/speed_test_result.dart | 198 ++- .../entities/speed_test_result.freezed.dart | 1288 ++++++++++++-- .../domain/entities/speed_test_result.g.dart | 92 +- .../entities/speed_test_with_results.dart | 69 + .../speed_test_with_results.freezed.dart | 271 +++ .../repositories/speed_test_repository.dart | 55 + .../providers/speed_test_providers.dart | 259 +++ .../providers/speed_test_providers.g.dart | 419 +++++ .../presentation/widgets/speed_test_card.dart | 113 +- .../widgets/speed_test_popup.dart | 178 +- 15 files changed, 4821 insertions(+), 186 deletions(-) create mode 100644 docs/IPERF3_IMPLEMENTATION.md create mode 100644 lib/features/speed_test/data/datasources/speed_test_data_source.dart create mode 100644 lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart create mode 100644 lib/features/speed_test/data/repositories/speed_test_repository_impl.dart create mode 100644 lib/features/speed_test/domain/entities/speed_test_with_results.dart create mode 100644 lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart create mode 100644 lib/features/speed_test/domain/repositories/speed_test_repository.dart create mode 100644 lib/features/speed_test/presentation/providers/speed_test_providers.dart create mode 100644 lib/features/speed_test/presentation/providers/speed_test_providers.g.dart diff --git a/docs/IPERF3_IMPLEMENTATION.md b/docs/IPERF3_IMPLEMENTATION.md new file mode 100644 index 0000000..2cb1f45 --- /dev/null +++ b/docs/IPERF3_IMPLEMENTATION.md @@ -0,0 +1,1527 @@ +# iPerf3 Speed Test Implementation + +## Overview + +This document describes the iPerf3 speed test implementation in the FDK (Field Development Kit) application. The system provides network performance measurement using native iPerf3 binaries on iOS and Android platforms. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Flutter (Dart) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────┐ │ +│ │ SpeedTestService │────▶│ Iperf3Service │ │ +│ │ (Orchestrator) │ │ (Native Bridge) │ │ +│ │ │ │ │ │ +│ │ • Test lifecycle │ │ • MethodChannel │ │ +│ │ • Server fallback │ │ • EventChannel │ │ +│ │ • Progress streams │ │ • JSON parsing │ │ +│ └─────────────────────┘ └──────────┬──────────┘ │ +│ │ │ +│ ┌─────────────────────┐ │ │ +│ │ NetworkGatewayService│ │ │ +│ │ │ │ │ +│ │ • Gateway detection│ │ │ +│ │ • WiFi info │ │ │ +│ └─────────────────────┘ │ │ +│ │ │ +└─────────────────────────────────────────┼───────────────────────────────────┘ + │ + MethodChannel: com.rgnets.fdk/iperf3 + EventChannel: com.rgnets.fdk/iperf3_progress + │ +┌─────────────────────────────────────────┼───────────────────────────────────┐ +│ Native Platform │ +├─────────────────────────────────────────┼───────────────────────────────────┤ +│ │ │ +│ ┌──────────────────────────────────────┴──────────────────────────────┐ │ +│ │ Platform Channel Handler │ │ +│ │ │ │ +│ │ iOS: Iperf3Plugin.swift │ │ +│ │ Android: Iperf3Plugin.kt │ │ +│ └──────────────────────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────────────┴──────────────────────────────┐ │ +│ │ iPerf3 Native Binary │ │ +│ │ │ │ +│ │ iOS: libiperf.a (static library) │ │ +│ │ Android: libiperf3.so (shared library per ABI) │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## File Structure + +``` +lib/features/speed_test/ +├── data/ +│ ├── datasources/ +│ │ ├── speed_test_data_source.dart # Abstract interface +│ │ └── speed_test_websocket_data_source.dart # WebSocket implementation +│ ├── repositories/ +│ │ └── speed_test_repository_impl.dart # Repository implementation +│ └── services/ +│ ├── iperf3_service.dart # Native bridge service +│ ├── speed_test_service.dart # Main orchestrator +│ └── network_gateway_service.dart # Network utilities +├── domain/ +│ ├── entities/ +│ │ ├── speed_test_config.dart # Test configuration (Freezed) +│ │ ├── speed_test_result.dart # Test result (Freezed) +│ │ ├── speed_test_with_results.dart # Joined entity +│ │ └── speed_test_status.dart # Status enum +│ └── repositories/ +│ └── speed_test_repository.dart # Repository interface +└── presentation/ + ├── providers/ + │ └── speed_test_providers.dart # Riverpod providers + └── widgets/ + ├── speed_test_card.dart # UI card widget + └── speed_test_popup.dart # Test popup dialog +``` + +--- + +## Core Components + +### 1. Iperf3Service (Native Bridge) + +**File:** `lib/features/speed_test/data/services/iperf3_service.dart` + +This service acts as a bridge between Flutter and the native iPerf3 implementation. + +#### Platform Channels + +```dart +static const MethodChannel _channel = MethodChannel('com.rgnets.fdk/iperf3'); +static const EventChannel _progressChannel = EventChannel('com.rgnets.fdk/iperf3_progress'); +``` + +#### Available Methods + +| Method | Description | Parameters | +|--------|-------------|------------| +| `runClient()` | Execute speed test | host, port, duration, streams, reverse, useUdp, bandwidth | +| `startServer()` | Start iPerf3 server mode | port, useUdp | +| `stopServer()` | Stop running server | - | +| `cancelClient()` | Cancel running test | - | +| `getVersion()` | Get iPerf3 version | - | +| `getDefaultGateway()` | Get device gateway IP | - | +| `getGatewayForDestination()` | Get gateway for hostname | hostname | + +#### runClient() Parameters + +```dart +Future> runClient({ + required String serverHost, // Target server IP/hostname + int port = 5201, // iPerf3 port (default 5201) + int durationSeconds = 10, // Test duration per phase + int parallelStreams = 1, // Concurrent streams + bool reverse = false, // true=download, false=upload + bool useUdp = true, // UDP or TCP protocol + int? bandwidthMbps, // Bandwidth limit (UDP only) +}) +``` + +#### Return Value Structure + +```dart +{ + 'success': bool, // Test completed successfully + 'error': String?, // Error message if failed + + // Speed measurements + 'sendMbps': double, // Upload speed in Mbps + 'receiveMbps': double, // Download speed in Mbps + 'sentBytes': int, // Total bytes sent + 'receivedBytes': int, // Total bytes received + + // Latency (protocol-dependent) + 'rtt': double, // TCP: Round-trip time (ms) + 'jitter': double, // UDP: Jitter (ms) + + // UDP-specific + 'lostPackets': int, // Packets lost + 'totalPackets': int, // Total packets + 'lostPercent': double, // Packet loss percentage + + 'jsonOutput': String, // Raw iPerf3 JSON output +} +``` + +--- + +### 2. SpeedTestService (Orchestrator) - In Depth + +**File:** `lib/features/speed_test/data/services/speed_test_service.dart` + +The SpeedTestService is the main orchestrator that manages the complete speed test lifecycle. It coordinates between the native iPerf3 bridge, network gateway detection, and provides reactive streams for UI updates. + +--- + +#### Singleton Pattern + +The service uses a singleton pattern to ensure only one instance exists throughout the app lifecycle: + +```dart +class SpeedTestService { + // Private singleton instance + static final SpeedTestService _instance = SpeedTestService._internal(); + + // Factory constructor returns the singleton + factory SpeedTestService() => _instance; + + // Private internal constructor + SpeedTestService._internal(); + + // Dependencies + final Iperf3Service _iperf3Service = Iperf3Service(); + final NetworkGatewayService _gatewayService = NetworkGatewayService(); +} +``` + +**Why Singleton?** +- Ensures consistent state across the app +- Prevents multiple simultaneous tests +- Maintains single connection to native layer +- Preserves configuration and last result + +--- + +#### Internal State Management + +The service maintains several pieces of internal state: + +```dart +// ═══════════════════════════════════════════════════════════════════ +// CONFIGURATION STATE (persisted to SharedPreferences) +// ═══════════════════════════════════════════════════════════════════ +String _serverHost = ''; // Current/last tested server +String _serverLabel = ''; // Human-readable server name +int _serverPort = 5201; // iPerf3 port +int _testDuration = 10; // Seconds per phase +bool _useUdp = true; // Protocol selection +int _bandwidthMbps = 81; // UDP bandwidth limit +int _parallelStreams = 16; // Concurrent streams + +// ═══════════════════════════════════════════════════════════════════ +// RUNTIME STATE (not persisted) +// ═══════════════════════════════════════════════════════════════════ +SpeedTestStatus _status = SpeedTestStatus.idle; // Current status +SpeedTestResult? _lastResult; // Most recent result +double _progress = 0.0; // 0-100% + +// Phase tracking for live updates +bool _isDownloadPhase = true; // Which phase active +bool _isRetryingFallback = false; // Suppress errors during retry + +// Speed preservation across phases +double _completedDownloadSpeed = 0.0; // After download completes +double _completedUploadSpeed = 0.0; // After upload completes +``` + +--- + +#### Default Configuration + +| Parameter | Default | Description | Why This Value | +|-----------|---------|-------------|----------------| +| `serverPort` | 5201 | Standard iPerf3 port | Industry standard | +| `testDuration` | 10 sec | Duration per test phase | Balance of accuracy vs time | +| `useUdp` | true | UDP protocol | More accurate for WiFi | +| `bandwidthMbps` | 81 | Target bandwidth | Prevents network saturation | +| `parallelStreams` | 16 | Concurrent streams | Maximizes throughput measurement | + +--- + +#### Reactive Streams Architecture + +The service exposes four broadcast streams for UI reactivity: + +```dart +// ═══════════════════════════════════════════════════════════════════ +// STREAM CONTROLLERS (broadcast = multiple listeners allowed) +// ═══════════════════════════════════════════════════════════════════ + +final StreamController _statusController = + StreamController.broadcast(); + +final StreamController _resultController = + StreamController.broadcast(); + +final StreamController _progressController = + StreamController.broadcast(); + +final StreamController _statusMessageController = + StreamController.broadcast(); + +// ═══════════════════════════════════════════════════════════════════ +// PUBLIC STREAM GETTERS +// ═══════════════════════════════════════════════════════════════════ + +// Status: idle → running → completed/error +Stream get statusStream => _statusController.stream; + +// Results: emits live updates DURING test + final result +Stream get resultStream => _resultController.stream; + +// Progress: 0.0 to 100.0 percentage +Stream get progressStream => _progressController.stream; + +// Messages: "Testing download speed...", "Connected to gateway", etc. +Stream get statusMessageStream => _statusMessageController.stream; +``` + +**Stream Data Flow:** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Native iPerf3 Progress │ +│ │ +│ EventChannel: com.rgnets.fdk/iperf3_progress │ +│ Emits: { status, interval, mbps, details } │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ _progressSubscription.listen() │ +│ │ +│ Receives native events and routes to appropriate handler │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌───────────────────────────┐ ┌───────────────────────────┐ +│ _handleStatusUpdate() │ │ _handleProgressUpdate() │ +│ │ │ │ +│ • Updates _status │ │ • Calculates progress % │ +│ • Emits status stream │ │ • Emits progress stream │ +│ • Emits message stream │ │ • Creates live result │ +│ • Handles errors │ │ • Emits result stream │ +└───────────────────────────┘ └───────────────────────────┘ + │ │ + ▼ ▼ + ┌───────────┐ ┌───────────┐ + │ UI │ │ UI │ + │ Widgets │ │ Widgets │ + └───────────┘ └───────────┘ +``` + +--- + +#### Initialization Process + +```dart +Future initialize() async { + // 1. Get SharedPreferences instance + _prefs = await SharedPreferences.getInstance(); + + // 2. Load saved configuration + await _loadConfiguration(); + + // 3. Override with optimal defaults (UDP, 16 streams, 81 Mbps) + _useUdp = true; + _parallelStreams = 16; + _bandwidthMbps = 81; + + // 4. Save the configuration + await _saveConfiguration(); + + // 5. Load last result (for UI display on app start) + await _loadLastResult(); + + // 6. Subscribe to native progress stream + _progressSubscription = _iperf3Service.getProgressStream().listen((progress) { + final status = progress['status']; + if (status != null && status is String) { + _handleStatusUpdate(status, progress['details']); + } else { + _handleProgressUpdate(progress); + } + }); +} +``` + +--- + +#### Test Execution Flow (Detailed) + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ runSpeedTestWithFallback() │ +│ │ +│ Entry point for running a speed test with automatic retry │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ STEP 1: Guard Against Concurrent Tests │ +│ │ +│ if (_status == SpeedTestStatus.running) { │ +│ LoggerService.warning('Speed test already running'); │ +│ return; // Don't start another test │ +│ } │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ STEP 2: Initialize Test State │ +│ │ +│ _updateStatus(SpeedTestStatus.running); // Notify UI │ +│ _progress = 0.0; │ +│ _progressController.add(_progress); │ +│ _isRetryingFallback = true; // Suppress errors │ +│ _completedDownloadSpeed = 0.0; // Reset from last test │ +│ _completedUploadSpeed = 0.0; │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ STEP 3: Get Local IP Address │ +│ │ +│ final localIp = await _getLocalIpAddress(); │ +�� │ +│ // Iterates through network interfaces │ +│ // Returns first non-loopback IPv4 address │ +│ // Used to identify device in results │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ STEP 4: Build Fallback Server List │ +│ │ +│ final fallbackServers = await _buildFallbackList(configTarget); │ +│ │ +│ Priority Order: │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 1. Default Gateway (e.g., 192.168.1.1) │ │ +│ │ - Detected via NetworkGatewayService │ │ +│ │ - Fastest, tests local network │ │ +│ │ - Requires iPerf3 server running on gateway │ │ +│ ├─────────────────────────────────────────────────────────────┤ │ +│ │ 2. Config Target (from SpeedTestConfig.target) │ │ +│ │ - Only added if different from gateway │ │ +│ │ - May be external server hostname/IP │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ STEP 5: Server Iteration Loop │ +│ │ +│ for (int i = 0; i < fallbackServers.length; i++) { │ +│ final serverHost = fallbackServers[i]['host']; │ +│ final serverLabel = fallbackServers[i]['label']; │ +│ │ +│ // Show progress to user │ +│ _statusMessageController.add( │ +│ 'Attempt ${i+1}/${fallbackServers.length}: $serverLabel' │ +│ ); │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ STEP 6: Run Test With Server (_runTestWithServer) │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ PHASE 1: DOWNLOAD TEST │ │ +│ │ │ │ +│ │ _isDownloadPhase = true; │ │ +│ │ │ │ +│ │ downloadResult = await _iperf3Service.runClient( │ │ +│ │ serverHost: serverHost, │ │ +│ │ port: 5201, │ │ +│ │ durationSeconds: 10, │ │ +│ │ parallelStreams: 16, │ │ +│ │ reverse: true, // Server → Client = DOWNLOAD │ │ +│ │ useUdp: true, │ │ +│ │ bandwidthMbps: 81, │ │ +│ │ ); │ │ +│ │ │ │ +│ │ if (!success) return null; // Try next server │ │ +│ │ │ │ +│ │ _completedDownloadSpeed = downloadResult['receiveMbps']; │ │ +│ │ latency = downloadResult['jitter']; // or 'rtt' for TCP │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ PHASE 2: UPLOAD TEST │ │ +│ │ │ │ +│ │ _isDownloadPhase = false; │ │ +│ │ │ │ +│ │ uploadResult = await _iperf3Service.runClient( │ │ +│ │ serverHost: serverHost, │ │ +│ │ port: 5201, │ │ +│ │ durationSeconds: 10, │ │ +│ │ parallelStreams: 16, │ │ +│ │ reverse: false, // Client → Server = UPLOAD │ │ +│ │ useUdp: true, │ │ +│ │ bandwidthMbps: 81, │ │ +│ │ ); │ │ +│ │ │ │ +│ │ if (!success) return null; // Try next server │ │ +│ │ │ │ +│ │ uploadSpeed = uploadResult['sendMbps']; │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ CREATE RESULT │ │ +│ │ │ │ +│ │ return SpeedTestResult( │ │ +│ │ downloadMbps: downloadSpeed, │ │ +│ │ uploadMbps: uploadSpeed, │ │ +│ │ rtt: latency, │ │ +│ │ completedAt: DateTime.now(), │ │ +│ │ localIpAddress: localIp, │ │ +│ │ serverHost: serverHost, │ │ +│ │ ); │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────┴───────────┐ + ▼ ▼ + ┌───────────┐ ┌───────────────┐ + │ Success │ │ Failed │ + │ │ │ │ + │ result │ │ result == │ + │ != null │ │ null │ + └─────┬─────┘ └───────┬───────┘ + │ │ + ▼ ▼ +┌─────────────────────────────┐ ┌─────────────────────────────┐ +│ STEP 7A: Success Path │ │ STEP 7B: Failure Path │ +│ │ │ │ +│ _isRetryingFallback=false; │ │ if (more servers left) { │ +│ _lastResult = result; │ │ // 1 second pause │ +│ _resultController.add(); │ │ await Future.delayed(); │ +│ _updateStatus(completed); │ │ continue; // Try next │ +│ _saveLastResult(result); │ │ } else { │ +│ return; // Exit loop │ │ _setErrorResult( │ +│ │ │ 'Unable to connect' │ +│ │ │ ); │ +│ │ │ } │ +└─────────────────────────────┘ └─────────────────────────────┘ +``` + +--- + +#### Live Progress Updates + +During test execution, the native layer sends progress events that are processed to provide real-time UI updates: + +```dart +void _handleProgressUpdate(Map progress) { + final interval = progress['interval'] as int?; // Current second + final speedMbps = progress['mbps'] as double?; // Current speed + + if (interval != null && _testDuration > 0) { + // Calculate percentage (0-100) + _progress = (interval / _testDuration * 100).clamp(0.0, 100.0); + _progressController.add(_progress); + + // Emit live result for UI updates + if (speedMbps != null && speedMbps > 0) { + final liveResult = SpeedTestResult( + // During download: show live download, no upload yet + // During upload: preserve completed download, show live upload + downloadMbps: _isDownloadPhase ? speedMbps : _completedDownloadSpeed, + uploadMbps: !_isDownloadPhase ? speedMbps : _completedUploadSpeed, + rtt: 0.0, + completedAt: DateTime.now(), + ); + + _resultController.add(liveResult); + } + } +} +``` + +**Visual Timeline:** + +``` +Download Phase (10 seconds) Upload Phase (10 seconds) +├──────────────────────────────────────────┼──────────────────────────────────────────┤ +│ sec 1: emit(down: 45.2, up: 0) │ sec 1: emit(down: 95.5, up: 12.3) │ +│ sec 2: emit(down: 67.8, up: 0) │ sec 2: emit(down: 95.5, up: 28.7) │ +│ sec 3: emit(down: 82.1, up: 0) │ sec 3: emit(down: 95.5, up: 35.4) │ +│ ... │ ... │ +│ sec 10: _completedDownloadSpeed = 95.5 │ sec 10: FINAL RESULT │ +│ switch to upload phase │ down: 95.5, up: 42.3 │ +└──────────────────────────────────────────┴──────────────────────────────────────────┘ +``` + +--- + +#### Status Message Generation + +Human-readable messages are generated based on the current state: + +```dart +void _handleStatusUpdate(String status, dynamic details) { + String getMessage() { + final serverInfo = _serverHost.isNotEmpty ? ' to $_serverHost' : ''; + + switch (status) { + case 'starting': + return 'Starting speed test...'; + + case 'running': + if (_isDownloadPhase) { + return 'Testing download speed$serverInfo...'; + } else { + return 'Testing upload speed$serverInfo...'; + } + + case 'completed': + return 'Test completed!'; + + case 'cancelled': + return 'Test cancelled'; + + case 'error': + final message = (details is Map && details['message'] != null) + ? details['message'].toString() + : 'Speed test failed'; + return 'Error: $message'; + + case 'idle': + return 'Ready'; + + default: + return 'Performing speed test$serverInfo...'; + } + } + + _statusMessageController.add(getMessage()); +} +``` + +--- + +#### Persistence (SharedPreferences) + +Configuration and last result are persisted for app restarts: + +```dart +// ═══════════════════════════════════════════════════════════════════ +// KEYS USED IN SHARED PREFERENCES +// ═══════════════════════════════════════════════════════════════════ +// 'speed_test_server_host' → String +// 'speed_test_server_port' → int +// 'speed_test_duration' → int +// 'speed_test_use_udp' → bool +// 'speed_test_bandwidth_mbps' → int +// 'speed_test_parallel_streams' → int +// 'speed_test_last_result' → String (JSON) + +Future _saveLastResult(SpeedTestResult result) async { + // Use compute() for JSON encoding on isolate (prevents UI jank) + final json = await compute(_encodeJson, result.toJson()); + await _prefs?.setString('speed_test_last_result', json); +} + +Future _loadLastResult() async { + final resultJson = _prefs?.getString('speed_test_last_result'); + if (resultJson != null) { + // Parse on isolate + final map = Map.from( + await compute(_parseJson, resultJson), + ); + _lastResult = SpeedTestResult.fromJson(map); + } +} +``` + +--- + +#### Error Handling Strategy + +```dart +// During fallback retry, errors are suppressed to avoid confusing the user +if (!_isRetryingFallback) { + _updateStatus(SpeedTestStatus.error); + _statusMessageController.add('Error: $message'); + _setErrorResult(message); +} + +// Error result factory +void _setErrorResult(String message) { + final result = SpeedTestResult.error(message); // hasError=true, passed=false + _lastResult = result; + _resultController.add(result); + _updateStatus(SpeedTestStatus.error); +} +``` + +**User Experience:** +- During fallback: User sees "Trying gateway...", "Trying test configuration..." +- Only after ALL servers fail: User sees "Unable to connect to server" +- No confusing intermediate error messages + +--- + +#### Cleanup + +```dart +void dispose() { + _progressSubscription?.cancel(); // Stop listening to native events + _statusController.close(); // Close all stream controllers + _resultController.close(); + _progressController.close(); + _statusMessageController.close(); +} +``` + +--- + +## Submitting Results & Fetching Configurations + +This section explains how speed test results are submitted to the server and how configurations are retrieved. + +### Complete Data Flow + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 1. FETCH CONFIGURATIONS │ +│ │ +│ UI Widget │ +│ │ │ +│ │ ref.watch(speedTestConfigsNotifierProvider) │ +│ ▼ │ +│ SpeedTestConfigsNotifier │ +│ │ │ +│ │ repository.getSpeedTestConfigs() │ +│ ▼ │ +│ SpeedTestRepositoryImpl │ +│ │ │ +│ │ dataSource.getSpeedTestConfigs() │ +│ ▼ │ +│ SpeedTestWebSocketDataSource │ +│ │ │ +│ │ webSocketService.requestActionCable( │ +│ │ action: 'index', │ +│ │ resourceType: 'speed_tests' │ +│ │ ) │ +│ ▼ │ +│ Rails Server │ +│ │ │ +│ │ Returns: { data: [ {id: 1, name: "Office", target: "192.168.1.1", │ +│ │ min_download_mbps: 50, ...}, ... ] } │ +│ ▼ │ +│ List │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 2. RUN SPEED TEST │ +│ │ +│ User taps "Run Test" button │ +│ │ │ +│ │ speedTestService.runSpeedTestWithFallback( │ +│ │ configTarget: config.target // e.g., "192.168.1.1" │ +│ │ ) │ +│ ▼ │ +│ SpeedTestService runs iPerf3 test │ +│ │ │ +│ │ Download test (reverse=true) → Upload test (reverse=false) │ +│ ▼ │ +│ SpeedTestResult created locally │ +│ (downloadMbps: 95.5, uploadMbps: 42.3, rtt: 12.5, ...) │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ 3. SUBMIT RESULT TO SERVER │ +│ │ +│ UI receives result from speedTestService.resultStream │ +│ │ │ +│ │ // Add the speed_test_id to link result to config │ +│ │ final resultToSave = result.copyWith(speedTestId: config.id); │ +│ │ │ +│ │ ref.read(speedTestResultsNotifierProvider.notifier) │ +│ │ .createResult(resultToSave); │ +│ ▼ │ +│ SpeedTestResultsNotifier │ +│ │ │ +│ │ repository.createSpeedTestResult(result) │ +│ ▼ │ +│ SpeedTestRepositoryImpl │ +│ │ │ +│ │ dataSource.createSpeedTestResult(result) │ +│ ▼ │ +│ SpeedTestWebSocketDataSource │ +│ │ │ +│ │ webSocketService.requestActionCable( │ +│ │ action: 'create', │ +│ │ resourceType: 'speed_test_results', │ +│ │ additionalData: result.toJson() │ +│ │ ) │ +│ ▼ │ +│ Rails Server │ +│ │ │ +│ │ Creates record in speed_test_results table │ +│ │ Returns: { data: { id: 456, speed_test_id: 123, ... } } │ +│ ▼ │ +│ SpeedTestResult (with server-assigned id) │ +└─────────���───────────────────────────────────────────────────────────────────┘ +``` + +--- + +### Fetching Speed Test Configurations + +Configurations define HOW a speed test should be run and what thresholds determine pass/fail. + +#### Provider Usage + +```dart +// In your widget +class SpeedTestScreen extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + // Watch all configurations + final configsAsync = ref.watch(speedTestConfigsNotifierProvider); + + return configsAsync.when( + loading: () => CircularProgressIndicator(), + error: (e, _) => Text('Error: $e'), + data: (configs) { + return ListView.builder( + itemCount: configs.length, + itemBuilder: (context, index) { + final config = configs[index]; + return ListTile( + title: Text(config.name ?? 'Unnamed Test'), + subtitle: Text('Target: ${config.target}'), + trailing: Text(config.passing ? '✓ Pass' : '✗ Fail'), + onTap: () => _runTest(ref, config), + ); + }, + ); + }, + ); + } +} +``` + +#### Data Source Implementation + +```dart +// In SpeedTestWebSocketDataSource + +@override +Future> getSpeedTestConfigs() async { + if (!_webSocketService.isConnected) { + return []; + } + + final response = await _webSocketService.requestActionCable( + action: 'index', + resourceType: 'speed_tests', // Maps to SpeedTest model in Rails + ); + + final data = response.payload['data']; + if (data is List) { + return data + .map((json) => SpeedTestConfig.fromJson( + Map.from(json as Map), + )) + .toList(); + } + + return []; +} +``` + +#### SpeedTestConfig Entity + +```dart +@freezed +class SpeedTestConfig with _$SpeedTestConfig { + const factory SpeedTestConfig({ + int? id, + String? name, // "Office WiFi Test" + @JsonKey(name: 'test_type') String? testType, // "iperf3" + String? target, // "192.168.1.1" - server to test against + int? port, // 5201 + @JsonKey(name: 'iperf_protocol') String? iperfProtocol, // "udp" or "tcp" + @JsonKey(name: 'min_download_mbps') double? minDownloadMbps, // 50.0 + @JsonKey(name: 'min_upload_mbps') double? minUploadMbps, // 25.0 + int? period, // 60 (run every 60...) + @JsonKey(name: 'period_unit') String? periodUnit, // "minutes" + @JsonKey(name: 'starts_at') DateTime? startsAt, + @JsonKey(name: 'next_check_at') DateTime? nextCheckAt, + @Default(false) bool passing, // Current pass/fail status + // ... more fields + }) = _SpeedTestConfig; +} +``` + +--- + +### Submitting Speed Test Results + +After running a test, the result must be submitted to the server with the correct `speed_test_id`. + +#### Step-by-Step Process + +```dart +// 1. User selects a config and runs test +Future _runTest(WidgetRef ref, SpeedTestConfig config) async { + final speedTestService = SpeedTestService(); + + // 2. Run the test with the config's target server + await speedTestService.runSpeedTestWithFallback( + configTarget: config.target, // Use config's target as fallback server + ); +} + +// 3. Listen to results and submit +void _setupResultListener(WidgetRef ref, SpeedTestConfig config) { + final speedTestService = SpeedTestService(); + + speedTestService.resultStream.listen((result) { + // Only submit final results (not live updates) + if (result.hasError) { + // Handle error - don't submit + return; + } + + // 4. Add the speed_test_id to link this result to the config + final resultToSubmit = SpeedTestResult( + speedTestId: config.id, // CRITICAL: Links result to config + downloadMbps: result.downloadMbps, + uploadMbps: result.uploadMbps, + rtt: result.rtt, + jitter: result.jitter, + passed: _checkIfPassed(result, config), // Determine pass/fail + completedAt: DateTime.now(), + localIpAddress: result.localIpAddress, + serverHost: result.serverHost, + ); + + // 5. Submit to server via provider + ref.read(speedTestResultsNotifierProvider.notifier) + .createResult(resultToSubmit); + }); +} + +// Helper to determine pass/fail based on config thresholds +bool _checkIfPassed(SpeedTestResult result, SpeedTestConfig config) { + final downloadOk = config.minDownloadMbps == null || + (result.downloadMbps ?? 0) >= config.minDownloadMbps!; + + final uploadOk = config.minUploadMbps == null || + (result.uploadMbps ?? 0) >= config.minUploadMbps!; + + return downloadOk && uploadOk; +} +``` + +#### Data Source Create Implementation + +```dart +// In SpeedTestWebSocketDataSource + +@override +Future createSpeedTestResult(SpeedTestResult result) async { + if (!_webSocketService.isConnected) { + throw StateError('WebSocket not connected'); + } + + final response = await _webSocketService.requestActionCable( + action: 'create', + resourceType: 'speed_test_results', + additionalData: result.toJson(), // Includes speed_test_id + ); + + final data = response.payload['data']; + if (data != null) { + // Use validation to fix any swapped speeds in response + return SpeedTestResult.fromJsonWithValidation( + Map.from(data as Map), + ); + } + + throw Exception( + response.payload['error']?.toString() ?? 'Failed to create result', + ); +} +``` + +#### JSON Payload Sent to Server + +```json +{ + "command": "message", + "identifier": "{\"channel\":\"ResourceChannel\"}", + "data": "{ + \"action\": \"create\", + \"resource_type\": \"speed_test_results\", + \"speed_test_id\": 123, + \"download_mbps\": 95.5, + \"upload_mbps\": 42.3, + \"rtt\": 12.5, + \"jitter\": 2.1, + \"passed\": true, + \"completed_at\": \"2024-01-15T10:30:00.000Z\", + \"local_ip_address\": \"192.168.1.100\", + \"server_host\": \"192.168.1.1\" + }" +} +``` + +--- + +### Getting Configs with Their Results (Joined) + +To display a config along with its test history, use the joined entity: + +```dart +// Get a single config with all its results +final configWithResults = ref.watch( + speedTestWithResultsNotifierProvider(configId), +); + +configWithResults.when( + data: (joined) { + print('Config: ${joined.config.name}'); + print('Total Results: ${joined.resultCount}'); + print('Pass Rate: ${joined.passRate}%'); + print('Latest Speed: ${joined.latestResult?.downloadMbps} Mbps'); + print('Currently Passing: ${joined.isCurrentlyPassing}'); + print('Meets Download Req: ${joined.meetsDownloadRequirement}'); + }, + loading: () => ..., + error: (e, _) => ..., +); + +// Or get ALL configs with their results +final allTests = ref.watch(allSpeedTestsWithResultsNotifierProvider); + +allTests.when( + data: (tests) { + for (final test in tests) { + print('${test.config.name}: ${test.passRate}% pass rate'); + } + }, + // ... +); +``` + +#### Repository Implementation + +```dart +// In SpeedTestRepositoryImpl + +@override +Future> getSpeedTestWithResults( + int configId, +) async { + try { + // 1. Fetch the config + final config = await _dataSource.getSpeedTestConfig(configId); + + // 2. Fetch results WHERE speed_test_id = configId + final results = await _dataSource.getSpeedTestResults( + speedTestId: configId, + ); + + // 3. Join them into a single entity + return Right(SpeedTestWithResults( + config: config, + results: results, + )); + } catch (e) { + return Left(ServerFailure('Failed to load speed test: $e')); + } +} + +@override +Future>> getAllSpeedTestsWithResults() async { + try { + // 1. Fetch all configs + final configs = await _dataSource.getSpeedTestConfigs(); + + // 2. Fetch ALL results + final allResults = await _dataSource.getSpeedTestResults(); + + // 3. Group results by speed_test_id + final resultsByConfigId = >{}; + for (final result in allResults) { + if (result.speedTestId != null) { + resultsByConfigId + .putIfAbsent(result.speedTestId!, () => []) + .add(result); + } + } + + // 4. Join each config with its results + final joined = configs.map((config) { + return SpeedTestWithResults( + config: config, + results: resultsByConfigId[config.id] ?? [], + ); + }).toList(); + + return Right(joined); + } catch (e) { + return Left(ServerFailure('Failed to load speed tests: $e')); + } +} +``` + +--- + +### Complete Usage Example + +```dart +class SpeedTestWidget extends ConsumerStatefulWidget { + final SpeedTestConfig config; + + @override + ConsumerState createState() => _SpeedTestWidgetState(); +} + +class _SpeedTestWidgetState extends ConsumerState { + final _speedTestService = SpeedTestService(); + StreamSubscription? _resultSubscription; + + @override + void initState() { + super.initState(); + _setupResultListener(); + } + + void _setupResultListener() { + _resultSubscription = _speedTestService.resultStream.listen((result) { + // Check if this is a final result (not live update) + if (!result.hasError && _speedTestService.status == SpeedTestStatus.completed) { + _submitResult(result); + } + }); + } + + Future _submitResult(SpeedTestResult result) async { + // Create result with speed_test_id linking to config + final resultToSubmit = result.copyWith( + speedTestId: widget.config.id, + passed: _checkPassed(result), + ); + + // Submit via Riverpod + final saved = await ref + .read(speedTestResultsNotifierProvider.notifier) + .createResult(resultToSubmit); + + if (saved != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Result saved! ID: ${saved.id}')), + ); + + // Refresh the joined data + ref.invalidate(speedTestWithResultsNotifierProvider(widget.config.id!)); + } + } + + bool _checkPassed(SpeedTestResult result) { + final minDown = widget.config.minDownloadMbps ?? 0; + final minUp = widget.config.minUploadMbps ?? 0; + + return (result.downloadMbps ?? 0) >= minDown && + (result.uploadMbps ?? 0) >= minUp; + } + + Future _runTest() async { + await _speedTestService.runSpeedTestWithFallback( + configTarget: widget.config.target, + ); + } + + @override + void dispose() { + _resultSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _speedTestService.statusStream, + builder: (context, snapshot) { + final status = snapshot.data ?? SpeedTestStatus.idle; + + return Column( + children: [ + Text('Config: ${widget.config.name}'), + Text('Min Download: ${widget.config.minDownloadMbps} Mbps'), + Text('Min Upload: ${widget.config.minUploadMbps} Mbps'), + + ElevatedButton( + onPressed: status == SpeedTestStatus.running ? null : _runTest, + child: Text(status == SpeedTestStatus.running + ? 'Testing...' + : 'Run Test'), + ), + + // Show live results + StreamBuilder( + stream: _speedTestService.resultStream, + builder: (context, resultSnapshot) { + final result = resultSnapshot.data; + if (result == null) return SizedBox.shrink(); + + return Column( + children: [ + Text('Download: ${result.formattedDownloadSpeed}'), + Text('Upload: ${result.formattedUploadSpeed}'), + Text('Latency: ${result.formattedRtt}'), + ], + ); + }, + ), + ], + ); + }, + ); + } +} +``` + +--- + +### 3. NetworkGatewayService + +**File:** `lib/features/speed_test/data/services/network_gateway_service.dart` + +Handles network detection and gateway resolution. + +#### Gateway Detection + +**iOS:** +```dart +// Uses native getDefaultGateway() - reads system routing table +final gateway = await _iperf3Service.getDefaultGateway(); +``` + +**Android:** +```dart +// Calculates from WiFi IP and subnet mask +final wifiIP = await _networkInfo.getWifiIP(); // e.g., 192.168.1.100 +final subnetMask = await _networkInfo.getWifiSubmask(); // e.g., 255.255.255.0 + +// Network = IP & Mask = 192.168.1.0 +// Gateway = Network + 1 = 192.168.1.1 +``` + +--- + +## Protocol Comparison + +### TCP vs UDP + +| Feature | TCP | UDP | +|---------|-----|-----| +| **Latency Metric** | RTT (Round Trip Time) | Jitter | +| **Bandwidth Limit** | Not used | Required (81 Mbps default) | +| **Packet Loss** | Retransmitted | Measured | +| **Accuracy** | Higher for wired | Better for WiFi | +| **Default** | No | Yes | + +### Why UDP is Default + +1. **WiFi Performance**: TCP retransmissions can mask real WiFi issues +2. **Accurate Throughput**: Measures actual channel capacity +3. **Latency Metrics**: Jitter is more relevant for WiFi quality +4. **Bandwidth Control**: Prevents network saturation + +--- + +## iPerf3 JSON Output Parsing + +### TCP Response Structure + +```json +{ + "end": { + "sum_sent": { + "bits_per_second": 94500000, + "bytes": 118125000 + }, + "sum_received": { + "bits_per_second": 94200000, + "bytes": 117750000 + }, + "streams": [{ + "sender": { + "mean_rtt": 12500 // microseconds + } + }] + } +} +``` + +### UDP Response Structure + +```json +{ + "end": { + "sum": { + "jitter_ms": 2.5, + "lost_packets": 3, + "packets": 10000, + "lost_percent": 0.03 + }, + "sum_received": { + "bits_per_second": 81000000, + "bytes": 101250000 + } + } +} +``` + +### Reverse Mode Logic + +```dart +// Download Test (reverse=true): Server → Client +// sum_received = what client received FROM server = DOWNLOAD speed + +// Upload Test (reverse=false): Client → Server +// sum_received = what server received FROM client = UPLOAD speed +``` + +--- + +## Data Persistence + +### SpeedTestResult Entity + +```dart +@freezed +class SpeedTestResult with _$SpeedTestResult { + const factory SpeedTestResult({ + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, // FK to config + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, + @Default(false) bool passed, + @JsonKey(name: 'completed_at') DateTime? completedAt, + // ... additional fields + }) = _SpeedTestResult; +} +``` + +### Speed Swap Validation + +The system automatically detects and corrects swapped download/upload values: + +```dart +static SpeedTestResult fromJsonWithValidation(Map json) { + final processedJson = _preprocessJson(json); + return _$SpeedTestResultFromJson(processedJson); +} + +// Heuristics: +// 1. Download < 5 Mbps AND Upload > 50 Mbps → Swap +// 2. Upload > Download × 10 → Swap +``` + +--- + +## Server Fallback Strategy + +### Priority Order + +1. **Default Gateway** (e.g., 192.168.1.1) + - Fastest response time + - Tests local network performance + - Requires iPerf3 server on gateway + +2. **Test Configuration Target** + - From `SpeedTestConfig.target` + - Configured per deployment + - May be external server + +### Fallback Behavior + +``` +Attempt 1: Default Gateway (192.168.1.1) + │ + ├── Success → Return Result + │ + └── Fail → "Trying test configuration..." + │ + ▼ +Attempt 2: Config Target (speedtest.example.com) + │ + ├── Success → Return Result + │ + └── Fail → "Unable to connect to server" +``` + +--- + +## Usage Examples + +### Basic Speed Test + +```dart +final speedTestService = SpeedTestService(); +await speedTestService.initialize(); + +// Listen to results +speedTestService.resultStream.listen((result) { + print('Download: ${result.downloadMbps} Mbps'); + print('Upload: ${result.uploadMbps} Mbps'); + print('Latency: ${result.rtt} ms'); +}); + +// Run test with automatic fallback +await speedTestService.runSpeedTestWithFallback(); +``` + +### With Riverpod Providers + +```dart +// Watch all configs with results +final speedTests = ref.watch(allSpeedTestsWithResultsNotifierProvider); + +speedTests.when( + data: (tests) { + for (final test in tests) { + print('Config: ${test.config.name}'); + print('Latest: ${test.latestResult?.downloadMbps} Mbps'); + print('Pass Rate: ${test.passRate}%'); + } + }, + loading: () => CircularProgressIndicator(), + error: (e, _) => Text('Error: $e'), +); +``` + +### Save Result to Server + +```dart +final result = SpeedTestResult( + speedTestId: config.id, + downloadMbps: 95.5, + uploadMbps: 42.3, + rtt: 12.5, + passed: true, + completedAt: DateTime.now(), +); + +await ref.read(speedTestResultsNotifierProvider.notifier).createResult(result); +``` + +--- + +## Error Handling + +### Common Errors + +| Error | Cause | Solution | +|-------|-------|----------| +| "Connection refused" | No iPerf3 server | Check server is running | +| "Network unreachable" | No network | Check WiFi/cellular | +| "Timeout" | Server not responding | Try different server | +| "Busy" | Server in use | Wait and retry | + +### Error Result Factory + +```dart +factory SpeedTestResult.error(String message) { + return SpeedTestResult( + hasError: true, + errorMessage: message, + passed: false, + ); +} +``` + +--- + +## Native Implementation Notes + +### iOS + +- **Library**: `libiperf.a` (static library) +- **Integration**: Linked via Xcode project +- **Gateway Detection**: Uses system routing table (`SCNetworkReachability`) +- **Permissions**: None required for speed test + +### Android + +- **Library**: `libiperf3.so` (shared library) +- **ABIs**: arm64-v8a, armeabi-v7a, x86_64 +- **Gateway Detection**: Calculated from `WifiManager` +- **Permissions**: `ACCESS_WIFI_STATE`, `ACCESS_NETWORK_STATE` + +--- + +## Performance Considerations + +### Bandwidth Limiting + +UDP tests use 81 Mbps bandwidth limit to: +- Prevent network saturation +- Allow accurate measurements +- Avoid overwhelming routers + +### Parallel Streams + +16 parallel streams provide: +- Better throughput measurement +- Reduced impact of individual packet delays +- More accurate WiFi performance data + +### Test Duration + +10 seconds per phase (download/upload): +- Long enough for stable measurements +- Short enough for good UX +- Allows TCP slow-start to complete + +--- + +## Troubleshooting + +### Test Always Fails + +1. Check iPerf3 server is running: `iperf3 -s` +2. Verify port 5201 is open +3. Check firewall rules +4. Try TCP instead of UDP + +### Inconsistent Results + +1. Ensure stable WiFi connection +2. Check for network congestion +3. Try different bandwidth limit +4. Use fewer parallel streams + +### Gateway Detection Fails + +**iOS**: Should work automatically +**Android**: Check WiFi permissions are granted + +--- + +## Related Documentation + +- [iPerf3 Official Documentation](https://iperf.fr/iperf-doc.php) +- [Flutter Platform Channels](https://docs.flutter.dev/development/platform-integration/platform-channels) +- [Freezed Package](https://pub.dev/packages/freezed) +- [Riverpod Package](https://riverpod.dev/) diff --git a/lib/features/speed_test/data/datasources/speed_test_data_source.dart b/lib/features/speed_test/data/datasources/speed_test_data_source.dart new file mode 100644 index 0000000..ee9d7ba --- /dev/null +++ b/lib/features/speed_test/data/datasources/speed_test_data_source.dart @@ -0,0 +1,36 @@ +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; + +/// Abstract interface for speed test data source. +abstract class SpeedTestDataSource { + // ============================================================================ + // Speed Test Config Operations + // ============================================================================ + + /// Fetch all speed test configurations from remote + Future> getSpeedTestConfigs(); + + /// Fetch a specific speed test configuration by ID + Future getSpeedTestConfig(int id); + + // ============================================================================ + // Speed Test Result Operations + // ============================================================================ + + /// Fetch speed test results with optional filtering + Future> getSpeedTestResults({ + int? speedTestId, + int? accessPointId, + int? limit, + int? offset, + }); + + /// Fetch a specific speed test result by ID + Future getSpeedTestResult(int id); + + /// Create a new speed test result + Future createSpeedTestResult(SpeedTestResult result); + + /// Update an existing speed test result + Future updateSpeedTestResult(SpeedTestResult result); +} diff --git a/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart b/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart new file mode 100644 index 0000000..6aaf9f1 --- /dev/null +++ b/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart @@ -0,0 +1,262 @@ +import 'package:logger/logger.dart'; +import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/core/services/websocket_service.dart'; +import 'package:rgnets_fdk/features/speed_test/data/datasources/speed_test_data_source.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; + +/// WebSocket-based data source for speed test operations. +class SpeedTestWebSocketDataSource implements SpeedTestDataSource { + SpeedTestWebSocketDataSource({ + required WebSocketService webSocketService, + Logger? logger, + }) : _webSocketService = webSocketService, + _logger = logger ?? Logger(); + + final WebSocketService _webSocketService; + final Logger _logger; + + static const String _speedTestConfigResourceType = 'speed_tests'; + static const String _speedTestResultResourceType = 'speed_test_results'; + + // ============================================================================ + // Speed Test Config Operations + // ============================================================================ + + @override + Future> getSpeedTestConfigs() async { + _logger.i('SpeedTestWebSocketDataSource: getSpeedTestConfigs() called'); + + if (!_webSocketService.isConnected) { + _logger.w('SpeedTestWebSocketDataSource: WebSocket not connected'); + return []; + } + + try { + final response = await _webSocketService.requestActionCable( + action: 'index', + resourceType: _speedTestConfigResourceType, + ); + + final data = response.payload['data']; + LoggerService.info( + 'SpeedTestConfigs raw response: ${response.payload}', + tag: 'SpeedTestWS', + ); + if (data is List) { + LoggerService.info( + 'SpeedTestConfigs received ${data.length} configs', + tag: 'SpeedTestWS', + ); + for (int i = 0; i < data.length; i++) { + final json = data[i]; + LoggerService.info( + 'Config[$i]: id=${json['id']}, name=${json['name']}, target=${json['target']}', + tag: 'SpeedTestWS', + ); + } + return data + .map((dynamic json) => SpeedTestConfig.fromJson( + Map.from(json as Map), + )) + .toList(); + } + + LoggerService.warning('SpeedTestConfigs: data is not a List', tag: 'SpeedTestWS'); + return []; + } catch (e) { + _logger.e('SpeedTestWebSocketDataSource: Failed to get configs: $e'); + return []; + } + } + + @override + Future getSpeedTestConfig(int id) async { + _logger.i('SpeedTestWebSocketDataSource: getSpeedTestConfig($id) called'); + + if (!_webSocketService.isConnected) { + throw StateError('WebSocket not connected'); + } + + final response = await _webSocketService.requestActionCable( + action: 'show', + resourceType: _speedTestConfigResourceType, + additionalData: {'id': id}, + ); + + final data = response.payload['data']; + if (data != null) { + return SpeedTestConfig.fromJson(Map.from(data as Map)); + } + + throw Exception('Speed test config with id $id not found'); + } + + // ============================================================================ + // Speed Test Result Operations + // ============================================================================ + + @override + Future> getSpeedTestResults({ + int? speedTestId, + int? accessPointId, + int? limit, + int? offset, + }) async { + _logger.i( + 'SpeedTestWebSocketDataSource: getSpeedTestResults(' + 'speedTestId: $speedTestId, accessPointId: $accessPointId, ' + 'limit: $limit, offset: $offset) called', + ); + + if (!_webSocketService.isConnected) { + _logger.w('SpeedTestWebSocketDataSource: WebSocket not connected'); + return []; + } + + try { + final additionalData = {}; + if (speedTestId != null) additionalData['speed_test_id'] = speedTestId; + if (accessPointId != null) { + additionalData['access_point_id'] = accessPointId; + } + if (limit != null) additionalData['limit'] = limit; + if (offset != null) additionalData['offset'] = offset; + + final response = await _webSocketService.requestActionCable( + action: 'index', + resourceType: _speedTestResultResourceType, + additionalData: additionalData.isNotEmpty ? additionalData : null, + ); + + final data = response.payload['data']; + LoggerService.info( + 'SpeedTestResults raw response: ${response.payload}', + tag: 'SpeedTestWS', + ); + if (data is List) { + LoggerService.info( + 'SpeedTestResults received ${data.length} results', + tag: 'SpeedTestWS', + ); + for (var i = 0; i < data.length && i < 5; i++) { + final json = data[i] as Map; + LoggerService.info( + 'Result[$i]: id=${json['id']}, speed_test_id=${json['speed_test_id']}, ' + 'download=${json['download_mbps']}, upload=${json['upload_mbps']}', + tag: 'SpeedTestWS', + ); + } + if (data.length > 5) { + LoggerService.info('... and ${data.length - 5} more results', tag: 'SpeedTestWS'); + } + return data + .map((dynamic json) => SpeedTestResult.fromJsonWithValidation( + Map.from(json as Map), + )) + .toList(); + } + + LoggerService.warning('SpeedTestResults: data is not a List', tag: 'SpeedTestWS'); + return []; + } catch (e) { + _logger.e('SpeedTestWebSocketDataSource: Failed to get results: $e'); + return []; + } + } + + @override + Future getSpeedTestResult(int id) async { + _logger.i('SpeedTestWebSocketDataSource: getSpeedTestResult($id) called'); + + if (!_webSocketService.isConnected) { + throw StateError('WebSocket not connected'); + } + + final response = await _webSocketService.requestActionCable( + action: 'show', + resourceType: _speedTestResultResourceType, + additionalData: {'id': id}, + ); + + final data = response.payload['data']; + if (data != null) { + return SpeedTestResult.fromJsonWithValidation( + Map.from(data as Map), + ); + } + + throw Exception('Speed test result with id $id not found'); + } + + @override + Future createSpeedTestResult(SpeedTestResult result) async { + _logger.i('SpeedTestWebSocketDataSource: createSpeedTestResult() called'); + + if (!_webSocketService.isConnected) { + throw StateError('WebSocket not connected'); + } + + final jsonToSend = result.toJson(); + LoggerService.info( + 'createSpeedTestResult sending: $jsonToSend', + tag: 'SpeedTestWS', + ); + + final response = await _webSocketService.requestActionCable( + action: 'create', + resourceType: _speedTestResultResourceType, + additionalData: jsonToSend, + ); + + LoggerService.info( + 'createSpeedTestResult response: ${response.payload}', + tag: 'SpeedTestWS', + ); + + final data = response.payload['data']; + if (data != null) { + return SpeedTestResult.fromJsonWithValidation( + Map.from(data as Map), + ); + } + + throw Exception( + response.payload['error']?.toString() ?? + 'Failed to create speed test result', + ); + } + + @override + Future updateSpeedTestResult(SpeedTestResult result) async { + _logger.i( + 'SpeedTestWebSocketDataSource: updateSpeedTestResult(${result.id}) called', + ); + + if (!_webSocketService.isConnected) { + throw StateError('WebSocket not connected'); + } + + if (result.id == null) { + throw ArgumentError('Cannot update speed test result without id'); + } + + final response = await _webSocketService.requestActionCable( + action: 'update', + resourceType: _speedTestResultResourceType, + additionalData: result.toJson(), + ); + + final data = response.payload['data']; + if (data != null) { + return SpeedTestResult.fromJsonWithValidation( + Map.from(data as Map), + ); + } + + throw Exception( + response.payload['error']?.toString() ?? + 'Failed to update speed test result', + ); + } +} diff --git a/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart b/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart new file mode 100644 index 0000000..60e58cf --- /dev/null +++ b/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart @@ -0,0 +1,224 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:logger/logger.dart'; +import 'package:rgnets_fdk/core/errors/failures.dart'; +import 'package:rgnets_fdk/features/speed_test/data/datasources/speed_test_data_source.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/repositories/speed_test_repository.dart'; + +/// Implementation of [SpeedTestRepository] using WebSocket data source. +class SpeedTestRepositoryImpl implements SpeedTestRepository { + SpeedTestRepositoryImpl({ + required SpeedTestDataSource dataSource, + Logger? logger, + }) : _dataSource = dataSource, + _logger = logger ?? Logger(); + + final SpeedTestDataSource _dataSource; + final Logger _logger; + + // ============================================================================ + // Speed Test Config Operations + // ============================================================================ + + @override + Future>> getSpeedTestConfigs() async { + try { + _logger.i('SpeedTestRepositoryImpl: getSpeedTestConfigs() called'); + final configs = await _dataSource.getSpeedTestConfigs(); + _logger.i( + 'SpeedTestRepositoryImpl: Got ${configs.length} speed test configs', + ); + return Right(configs); + } on Exception catch (e) { + _logger.e('SpeedTestRepositoryImpl: Failed to get configs: $e'); + return Left(_mapExceptionToFailure(e)); + } + } + + @override + Future> getSpeedTestConfig(int id) async { + try { + _logger.i('SpeedTestRepositoryImpl: getSpeedTestConfig($id) called'); + final config = await _dataSource.getSpeedTestConfig(id); + return Right(config); + } on Exception catch (e) { + _logger.e('SpeedTestRepositoryImpl: Failed to get config $id: $e'); + return Left(_mapExceptionToFailure(e)); + } + } + + // ============================================================================ + // Speed Test Result Operations + // ============================================================================ + + @override + Future>> getSpeedTestResults({ + int? speedTestId, + int? accessPointId, + int? limit, + int? offset, + }) async { + try { + _logger.i( + 'SpeedTestRepositoryImpl: getSpeedTestResults(' + 'speedTestId: $speedTestId, accessPointId: $accessPointId) called', + ); + final results = await _dataSource.getSpeedTestResults( + speedTestId: speedTestId, + accessPointId: accessPointId, + limit: limit, + offset: offset, + ); + _logger.i( + 'SpeedTestRepositoryImpl: Got ${results.length} speed test results', + ); + return Right(results); + } on Exception catch (e) { + _logger.e('SpeedTestRepositoryImpl: Failed to get results: $e'); + return Left(_mapExceptionToFailure(e)); + } + } + + @override + Future> getSpeedTestResult(int id) async { + try { + _logger.i('SpeedTestRepositoryImpl: getSpeedTestResult($id) called'); + final result = await _dataSource.getSpeedTestResult(id); + return Right(result); + } on Exception catch (e) { + _logger.e('SpeedTestRepositoryImpl: Failed to get result $id: $e'); + return Left(_mapExceptionToFailure(e)); + } + } + + @override + Future> createSpeedTestResult( + SpeedTestResult result, + ) async { + try { + _logger.i('SpeedTestRepositoryImpl: createSpeedTestResult() called'); + final created = await _dataSource.createSpeedTestResult(result); + _logger.i('SpeedTestRepositoryImpl: Created result with id ${created.id}'); + return Right(created); + } on Exception catch (e) { + _logger.e('SpeedTestRepositoryImpl: Failed to create result: $e'); + return Left(_mapExceptionToFailure(e)); + } + } + + @override + Future> updateSpeedTestResult( + SpeedTestResult result, + ) async { + try { + _logger.i( + 'SpeedTestRepositoryImpl: updateSpeedTestResult(${result.id}) called', + ); + final updated = await _dataSource.updateSpeedTestResult(result); + return Right(updated); + } on Exception catch (e) { + _logger.e('SpeedTestRepositoryImpl: Failed to update result: $e'); + return Left(_mapExceptionToFailure(e)); + } + } + + // ============================================================================ + // Joined Operations + // ============================================================================ + + @override + Future> getSpeedTestWithResults( + int id, + ) async { + try { + _logger.i('SpeedTestRepositoryImpl: getSpeedTestWithResults($id) called'); + + // Fetch config and results in parallel + final configFuture = _dataSource.getSpeedTestConfig(id); + final resultsFuture = _dataSource.getSpeedTestResults(speedTestId: id); + + final config = await configFuture; + final results = await resultsFuture; + + final joined = SpeedTestWithResults( + config: config, + results: results, + ); + + _logger.i( + 'SpeedTestRepositoryImpl: Got config $id with ${results.length} results', + ); + return Right(joined); + } on Exception catch (e) { + _logger.e( + 'SpeedTestRepositoryImpl: Failed to get speed test with results: $e', + ); + return Left(_mapExceptionToFailure(e)); + } + } + + @override + Future>> + getAllSpeedTestsWithResults() async { + try { + _logger.i( + 'SpeedTestRepositoryImpl: getAllSpeedTestsWithResults() called', + ); + + // Fetch all configs and results + final configs = await _dataSource.getSpeedTestConfigs(); + final allResults = await _dataSource.getSpeedTestResults(); + + // Group results by speedTestId + final resultsByConfigId = >{}; + for (final result in allResults) { + if (result.speedTestId != null) { + resultsByConfigId + .putIfAbsent(result.speedTestId!, () => []) + .add(result); + } + } + + // Join configs with their results + final joined = configs.map((config) { + final results = config.id != null + ? (resultsByConfigId[config.id!] ?? []) + : []; + return SpeedTestWithResults(config: config, results: results); + }).toList(); + + _logger.i( + 'SpeedTestRepositoryImpl: Got ${joined.length} speed tests with results', + ); + return Right(joined); + } on Exception catch (e) { + _logger.e( + 'SpeedTestRepositoryImpl: Failed to get all speed tests with results: $e', + ); + return Left(_mapExceptionToFailure(e)); + } + } + + // ============================================================================ + // Helper Methods + // ============================================================================ + + Failure _mapExceptionToFailure(Exception exception) { + final message = exception.toString(); + + if (message.contains('not found') || message.contains('404')) { + return NotFoundFailure(message: message); + } else if (message.contains('not connected') || + message.contains('network')) { + return NetworkFailure(message: message); + } else if (message.contains('timeout')) { + return TimeoutFailure(message: message); + } else if (message.contains('server') || message.contains('500')) { + return ServerFailure(message: message); + } + + return ServerFailure(message: 'Speed test operation failed: $message'); + } +} diff --git a/lib/features/speed_test/data/services/speed_test_service.dart b/lib/features/speed_test/data/services/speed_test_service.dart index d35bfc0..0e94fc4 100644 --- a/lib/features/speed_test/data/services/speed_test_service.dart +++ b/lib/features/speed_test/data/services/speed_test_service.dart @@ -176,11 +176,11 @@ class SpeedTestService { // Create a partial result for live updates based on current phase // Preserve completed phase speed so UI shows both download AND upload final liveResult = SpeedTestResult( - downloadSpeed: + downloadMbps: _isDownloadPhase ? speedMbps : _completedDownloadSpeed, - uploadSpeed: !_isDownloadPhase ? speedMbps : _completedUploadSpeed, - latency: 0.0, - timestamp: DateTime.now(), + uploadMbps: !_isDownloadPhase ? speedMbps : _completedUploadSpeed, + rtt: 0.0, + completedAt: DateTime.now(), ); _resultController.add(liveResult); @@ -413,10 +413,10 @@ class SpeedTestService { // Create result return SpeedTestResult( - downloadSpeed: (downloadSpeed as num).toDouble(), - uploadSpeed: (uploadSpeed as num).toDouble(), - latency: (latency as num).toDouble(), - timestamp: DateTime.now(), + downloadMbps: (downloadSpeed as num).toDouble(), + uploadMbps: (uploadSpeed as num).toDouble(), + rtt: (latency as num).toDouble(), + completedAt: DateTime.now(), localIpAddress: localIp, serverHost: serverHost, ); diff --git a/lib/features/speed_test/domain/entities/speed_test_result.dart b/lib/features/speed_test/domain/entities/speed_test_result.dart index f1a876e..120c46f 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:rgnets_fdk/core/services/logger_service.dart'; part 'speed_test_result.freezed.dart'; part 'speed_test_result.g.dart'; @@ -6,50 +7,207 @@ part 'speed_test_result.g.dart'; @freezed class SpeedTestResult with _$SpeedTestResult { const factory SpeedTestResult({ - required double downloadSpeed, - required double uploadSpeed, - required double latency, - required DateTime timestamp, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(name: 'test_type') String? testType, + String? source, + String? destination, + int? port, + @JsonKey(name: 'iperf_protocol') String? iperfProtocol, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, + @Default(false) bool passed, + @JsonKey(name: 'is_applicable') @Default(true) bool isApplicable, + @JsonKey(name: 'initiated_at') DateTime? initiatedAt, + @JsonKey(name: 'completed_at') DateTime? completedAt, + String? raw, + @JsonKey(name: 'image_url') String? imageUrl, + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'room_type') String? roomType, + @JsonKey(name: 'admin_id') int? adminId, + String? note, + String? scratch, + @JsonKey(name: 'created_by') String? createdBy, + @JsonKey(name: 'updated_by') String? updatedBy, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, + // Legacy fields for backwards compatibility @Default(false) bool hasError, String? errorMessage, - String? localIpAddress, - String? serverHost, + @JsonKey(name: 'local_ip_address') String? localIpAddress, + @JsonKey(name: 'server_host') String? serverHost, }) = _SpeedTestResult; const SpeedTestResult._(); + /// Standard fromJson generated by json_serializable factory SpeedTestResult.fromJson(Map json) => _$SpeedTestResultFromJson(json); + /// FromJson with validation that corrects potentially swapped speeds + /// Use this when parsing data from the API to ensure correct values + static SpeedTestResult fromJsonWithValidation(Map json) { + // Pre-process the JSON to fix swapped speeds before Freezed parsing + final processedJson = _preprocessJson(json); + return _$SpeedTestResultFromJson(processedJson); + } + + /// Factory for creating an error result factory SpeedTestResult.error(String message) { return SpeedTestResult( - downloadSpeed: 0, - uploadSpeed: 0, - latency: 0, - timestamp: DateTime.now(), hasError: true, errorMessage: message, + passed: false, ); } + /// Pre-process JSON to detect and correct swapped download/upload values + static Map _preprocessJson(Map json) { + final download = _parseDecimal(json['download_mbps']); + final upload = _parseDecimal(json['upload_mbps']); + + if (download == null || upload == null) { + return json; + } + + // Both are 0 - likely incomplete test, don't swap + if (download == 0 && upload == 0) { + return json; + } + + bool shouldSwap = false; + String? reason; + + // Heuristic 1: Download is suspiciously low AND upload is suspiciously high + if (download < 5.0 && upload > 50.0) { + shouldSwap = true; + reason = 'download too low (<5) and upload too high (>50)'; + } + // Heuristic 2: Upload is significantly higher than download (10x or more) + else if (download > 0 && upload > download * 10) { + shouldSwap = true; + reason = 'upload is 10x higher than download'; + } + + if (shouldSwap) { + LoggerService.warning( + 'Detected swapped speeds in result ${json['id'] ?? "unknown"}: ' + '$reason. Original: down=$download up=$upload, ' + 'Corrected: down=$upload up=$download', + tag: 'SpeedTestResult', + ); + // Create a new map with swapped values + return { + ...json, + 'download_mbps': upload, + 'upload_mbps': download, + }; + } + + return json; + } + + /// Helper to parse decimal values from various formats + static double? _parseDecimal(dynamic value) { + if (value == null) return null; + if (value is double) return value; + if (value is int) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; + } + + /// Check if this is an iperf3 test + bool get isIperfTest => + testType?.toLowerCase() == 'iperf3' || testType?.toLowerCase() == 'iperf'; + + /// Check if this uses UDP protocol + bool get isUdp => iperfProtocol?.toLowerCase() == 'udp'; + + /// Check if this uses TCP protocol + bool get isTcp => iperfProtocol?.toLowerCase() == 'tcp'; + + /// Get test duration if both timestamps are available + Duration? get testDuration { + if (initiatedAt == null || completedAt == null) return null; + return completedAt!.difference(initiatedAt!); + } + + /// Check if test is completed + bool get isCompleted => completedAt != null; + + /// Check if test is still running + bool get isRunning => initiatedAt != null && completedAt == null; + + /// Check if test has good performance (passed and applicable) + bool get isHealthy => passed && isApplicable; + + /// Get average speed (mean of download and upload) + double? get averageSpeedMbps { + if (downloadMbps == null && uploadMbps == null) return null; + final down = downloadMbps ?? 0.0; + final up = uploadMbps ?? 0.0; + return (down + up) / 2; + } + /// Get formatted download speed String get formattedDownloadSpeed { - if (downloadSpeed < 1000.0) { - return '${downloadSpeed.toStringAsFixed(2)} Mbps'; - } else { - return '${(downloadSpeed / 1000).toStringAsFixed(2)} Gbps'; + if (downloadMbps == null) return 'N/A'; + if (downloadMbps! < 1000.0) { + return '${downloadMbps!.toStringAsFixed(2)} Mbps'; } + return '${(downloadMbps! / 1000).toStringAsFixed(2)} Gbps'; } /// Get formatted upload speed String get formattedUploadSpeed { - if (uploadSpeed < 1000.0) { - return '${uploadSpeed.toStringAsFixed(2)} Mbps'; - } else { - return '${(uploadSpeed / 1000).toStringAsFixed(2)} Gbps'; + if (uploadMbps == null) return 'N/A'; + if (uploadMbps! < 1000.0) { + return '${uploadMbps!.toStringAsFixed(2)} Mbps'; } + return '${(uploadMbps! / 1000).toStringAsFixed(2)} Gbps'; + } + + /// Get formatted RTT (Round Trip Time) + String get formattedRtt { + if (rtt == null) return 'N/A'; + return '${rtt!.toStringAsFixed(2)} ms'; + } + + /// Get formatted jitter + String get formattedJitter { + if (jitter == null) return 'N/A'; + return '${jitter!.toStringAsFixed(2)} ms'; + } + + /// Get formatted packet loss + String get formattedPacketLoss { + if (packetLoss == null) return 'N/A'; + return '${packetLoss!.toStringAsFixed(2)}%'; } - /// Get formatted latency - String get formattedLatency => '${latency.toStringAsFixed(0)} ms'; + /// Get formatted latency (alias for RTT for backwards compatibility) + String get formattedLatency => formattedRtt; + + /// Legacy getter for downloadSpeed (maps to downloadMbps) + double get downloadSpeed => downloadMbps ?? 0.0; + + /// Legacy getter for uploadSpeed (maps to uploadMbps) + double get uploadSpeed => uploadMbps ?? 0.0; + + /// Legacy getter for latency (maps to rtt) + double get latency => rtt ?? 0.0; + + /// Legacy getter for timestamp (maps to completedAt or createdAt) + DateTime get timestamp => completedAt ?? createdAt ?? DateTime.now(); } diff --git a/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart b/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart index 510272d..2076854 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart @@ -20,53 +20,204 @@ SpeedTestResult _$SpeedTestResultFromJson(Map json) { /// @nodoc mixin _$SpeedTestResult { - double get downloadSpeed => throw _privateConstructorUsedError; - double get uploadSpeed => throw _privateConstructorUsedError; - double get latency => throw _privateConstructorUsedError; - DateTime get timestamp => throw _privateConstructorUsedError; + int? get id => throw _privateConstructorUsedError; + @JsonKey(name: 'speed_test_id') + int? get speedTestId => throw _privateConstructorUsedError; + @JsonKey(name: 'test_type') + String? get testType => throw _privateConstructorUsedError; + String? get source => throw _privateConstructorUsedError; + String? get destination => throw _privateConstructorUsedError; + int? get port => throw _privateConstructorUsedError; + @JsonKey(name: 'iperf_protocol') + String? get iperfProtocol => throw _privateConstructorUsedError; + @JsonKey(name: 'download_mbps') + double? get downloadMbps => throw _privateConstructorUsedError; + @JsonKey(name: 'upload_mbps') + double? get uploadMbps => throw _privateConstructorUsedError; + double? get rtt => throw _privateConstructorUsedError; + double? get jitter => throw _privateConstructorUsedError; + @JsonKey(name: 'packet_loss') + double? get packetLoss => throw _privateConstructorUsedError; + bool get passed => throw _privateConstructorUsedError; + @JsonKey(name: 'is_applicable') + bool get isApplicable => throw _privateConstructorUsedError; + @JsonKey(name: 'initiated_at') + DateTime? get initiatedAt => throw _privateConstructorUsedError; + @JsonKey(name: 'completed_at') + DateTime? get completedAt => throw _privateConstructorUsedError; + String? get raw => throw _privateConstructorUsedError; + @JsonKey(name: 'image_url') + String? get imageUrl => throw _privateConstructorUsedError; + @JsonKey(name: 'access_point_id') + int? get accessPointId => throw _privateConstructorUsedError; + @JsonKey(name: 'tested_via_access_point_id') + int? get testedViaAccessPointId => throw _privateConstructorUsedError; + @JsonKey(name: 'tested_via_access_point_radio_id') + int? get testedViaAccessPointRadioId => throw _privateConstructorUsedError; + @JsonKey(name: 'tested_via_media_converter_id') + int? get testedViaMediaConverterId => throw _privateConstructorUsedError; + @JsonKey(name: 'uplink_id') + int? get uplinkId => throw _privateConstructorUsedError; + @JsonKey(name: 'wlan_id') + int? get wlanId => throw _privateConstructorUsedError; + @JsonKey(name: 'pms_room_id') + int? get pmsRoomId => throw _privateConstructorUsedError; + @JsonKey(name: 'room_type') + String? get roomType => throw _privateConstructorUsedError; + @JsonKey(name: 'admin_id') + int? get adminId => throw _privateConstructorUsedError; + String? get note => throw _privateConstructorUsedError; + String? get scratch => throw _privateConstructorUsedError; + @JsonKey(name: 'created_by') + String? get createdBy => throw _privateConstructorUsedError; + @JsonKey(name: 'updated_by') + String? get updatedBy => throw _privateConstructorUsedError; + @JsonKey(name: 'created_at') + DateTime? get createdAt => throw _privateConstructorUsedError; + @JsonKey(name: 'updated_at') + DateTime? get updatedAt => + throw _privateConstructorUsedError; // Legacy fields for backwards compatibility bool get hasError => throw _privateConstructorUsedError; String? get errorMessage => throw _privateConstructorUsedError; + @JsonKey(name: 'local_ip_address') String? get localIpAddress => throw _privateConstructorUsedError; + @JsonKey(name: 'server_host') String? get serverHost => throw _privateConstructorUsedError; @optionalTypeArgs TResult when( TResult Function( - double downloadSpeed, - double uploadSpeed, - double latency, - DateTime timestamp, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(name: 'test_type') String? testType, + String? source, + String? destination, + int? port, + @JsonKey(name: 'iperf_protocol') String? iperfProtocol, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, + bool passed, + @JsonKey(name: 'is_applicable') bool isApplicable, + @JsonKey(name: 'initiated_at') DateTime? initiatedAt, + @JsonKey(name: 'completed_at') DateTime? completedAt, + String? raw, + @JsonKey(name: 'image_url') String? imageUrl, + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'room_type') String? roomType, + @JsonKey(name: 'admin_id') int? adminId, + String? note, + String? scratch, + @JsonKey(name: 'created_by') String? createdBy, + @JsonKey(name: 'updated_by') String? updatedBy, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, bool hasError, String? errorMessage, - String? localIpAddress, - String? serverHost) + @JsonKey(name: 'local_ip_address') String? localIpAddress, + @JsonKey(name: 'server_host') String? serverHost) $default, ) => throw _privateConstructorUsedError; @optionalTypeArgs TResult? whenOrNull( TResult? Function( - double downloadSpeed, - double uploadSpeed, - double latency, - DateTime timestamp, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(name: 'test_type') String? testType, + String? source, + String? destination, + int? port, + @JsonKey(name: 'iperf_protocol') String? iperfProtocol, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, + bool passed, + @JsonKey(name: 'is_applicable') bool isApplicable, + @JsonKey(name: 'initiated_at') DateTime? initiatedAt, + @JsonKey(name: 'completed_at') DateTime? completedAt, + String? raw, + @JsonKey(name: 'image_url') String? imageUrl, + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'room_type') String? roomType, + @JsonKey(name: 'admin_id') int? adminId, + String? note, + String? scratch, + @JsonKey(name: 'created_by') String? createdBy, + @JsonKey(name: 'updated_by') String? updatedBy, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, bool hasError, String? errorMessage, - String? localIpAddress, - String? serverHost)? + @JsonKey(name: 'local_ip_address') String? localIpAddress, + @JsonKey(name: 'server_host') String? serverHost)? $default, ) => throw _privateConstructorUsedError; @optionalTypeArgs TResult maybeWhen( TResult Function( - double downloadSpeed, - double uploadSpeed, - double latency, - DateTime timestamp, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(name: 'test_type') String? testType, + String? source, + String? destination, + int? port, + @JsonKey(name: 'iperf_protocol') String? iperfProtocol, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, + bool passed, + @JsonKey(name: 'is_applicable') bool isApplicable, + @JsonKey(name: 'initiated_at') DateTime? initiatedAt, + @JsonKey(name: 'completed_at') DateTime? completedAt, + String? raw, + @JsonKey(name: 'image_url') String? imageUrl, + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'room_type') String? roomType, + @JsonKey(name: 'admin_id') int? adminId, + String? note, + String? scratch, + @JsonKey(name: 'created_by') String? createdBy, + @JsonKey(name: 'updated_by') String? updatedBy, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, bool hasError, String? errorMessage, - String? localIpAddress, - String? serverHost)? + @JsonKey(name: 'local_ip_address') String? localIpAddress, + @JsonKey(name: 'server_host') String? serverHost)? $default, { required TResult orElse(), }) => @@ -100,14 +251,45 @@ abstract class $SpeedTestResultCopyWith<$Res> { _$SpeedTestResultCopyWithImpl<$Res, SpeedTestResult>; @useResult $Res call( - {double downloadSpeed, - double uploadSpeed, - double latency, - DateTime timestamp, + {int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(name: 'test_type') String? testType, + String? source, + String? destination, + int? port, + @JsonKey(name: 'iperf_protocol') String? iperfProtocol, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, + bool passed, + @JsonKey(name: 'is_applicable') bool isApplicable, + @JsonKey(name: 'initiated_at') DateTime? initiatedAt, + @JsonKey(name: 'completed_at') DateTime? completedAt, + String? raw, + @JsonKey(name: 'image_url') String? imageUrl, + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'room_type') String? roomType, + @JsonKey(name: 'admin_id') int? adminId, + String? note, + String? scratch, + @JsonKey(name: 'created_by') String? createdBy, + @JsonKey(name: 'updated_by') String? updatedBy, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, bool hasError, String? errorMessage, - String? localIpAddress, - String? serverHost}); + @JsonKey(name: 'local_ip_address') String? localIpAddress, + @JsonKey(name: 'server_host') String? serverHost}); } /// @nodoc @@ -123,32 +305,177 @@ class _$SpeedTestResultCopyWithImpl<$Res, $Val extends SpeedTestResult> @pragma('vm:prefer-inline') @override $Res call({ - Object? downloadSpeed = null, - Object? uploadSpeed = null, - Object? latency = null, - Object? timestamp = null, + Object? id = freezed, + Object? speedTestId = freezed, + Object? testType = freezed, + Object? source = freezed, + Object? destination = freezed, + Object? port = freezed, + Object? iperfProtocol = freezed, + Object? downloadMbps = freezed, + Object? uploadMbps = freezed, + Object? rtt = freezed, + Object? jitter = freezed, + Object? packetLoss = freezed, + Object? passed = null, + Object? isApplicable = null, + Object? initiatedAt = freezed, + Object? completedAt = freezed, + Object? raw = freezed, + Object? imageUrl = freezed, + Object? accessPointId = freezed, + Object? testedViaAccessPointId = freezed, + Object? testedViaAccessPointRadioId = freezed, + Object? testedViaMediaConverterId = freezed, + Object? uplinkId = freezed, + Object? wlanId = freezed, + Object? pmsRoomId = freezed, + Object? roomType = freezed, + Object? adminId = freezed, + Object? note = freezed, + Object? scratch = freezed, + Object? createdBy = freezed, + Object? updatedBy = freezed, + Object? createdAt = freezed, + Object? updatedAt = freezed, Object? hasError = null, Object? errorMessage = freezed, Object? localIpAddress = freezed, Object? serverHost = freezed, }) { return _then(_value.copyWith( - downloadSpeed: null == downloadSpeed - ? _value.downloadSpeed - : downloadSpeed // ignore: cast_nullable_to_non_nullable - as double, - uploadSpeed: null == uploadSpeed - ? _value.uploadSpeed - : uploadSpeed // ignore: cast_nullable_to_non_nullable - as double, - latency: null == latency - ? _value.latency - : latency // ignore: cast_nullable_to_non_nullable - as double, - timestamp: null == timestamp - ? _value.timestamp - : timestamp // ignore: cast_nullable_to_non_nullable - as DateTime, + id: freezed == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int?, + speedTestId: freezed == speedTestId + ? _value.speedTestId + : speedTestId // ignore: cast_nullable_to_non_nullable + as int?, + testType: freezed == testType + ? _value.testType + : testType // ignore: cast_nullable_to_non_nullable + as String?, + source: freezed == source + ? _value.source + : source // ignore: cast_nullable_to_non_nullable + as String?, + destination: freezed == destination + ? _value.destination + : destination // ignore: cast_nullable_to_non_nullable + as String?, + port: freezed == port + ? _value.port + : port // ignore: cast_nullable_to_non_nullable + as int?, + iperfProtocol: freezed == iperfProtocol + ? _value.iperfProtocol + : iperfProtocol // ignore: cast_nullable_to_non_nullable + as String?, + downloadMbps: freezed == downloadMbps + ? _value.downloadMbps + : downloadMbps // ignore: cast_nullable_to_non_nullable + as double?, + uploadMbps: freezed == uploadMbps + ? _value.uploadMbps + : uploadMbps // ignore: cast_nullable_to_non_nullable + as double?, + rtt: freezed == rtt + ? _value.rtt + : rtt // ignore: cast_nullable_to_non_nullable + as double?, + jitter: freezed == jitter + ? _value.jitter + : jitter // ignore: cast_nullable_to_non_nullable + as double?, + packetLoss: freezed == packetLoss + ? _value.packetLoss + : packetLoss // ignore: cast_nullable_to_non_nullable + as double?, + passed: null == passed + ? _value.passed + : passed // ignore: cast_nullable_to_non_nullable + as bool, + isApplicable: null == isApplicable + ? _value.isApplicable + : isApplicable // ignore: cast_nullable_to_non_nullable + as bool, + initiatedAt: freezed == initiatedAt + ? _value.initiatedAt + : initiatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + completedAt: freezed == completedAt + ? _value.completedAt + : completedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + raw: freezed == raw + ? _value.raw + : raw // ignore: cast_nullable_to_non_nullable + as String?, + imageUrl: freezed == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String?, + accessPointId: freezed == accessPointId + ? _value.accessPointId + : accessPointId // ignore: cast_nullable_to_non_nullable + as int?, + testedViaAccessPointId: freezed == testedViaAccessPointId + ? _value.testedViaAccessPointId + : testedViaAccessPointId // ignore: cast_nullable_to_non_nullable + as int?, + testedViaAccessPointRadioId: freezed == testedViaAccessPointRadioId + ? _value.testedViaAccessPointRadioId + : testedViaAccessPointRadioId // ignore: cast_nullable_to_non_nullable + as int?, + testedViaMediaConverterId: freezed == testedViaMediaConverterId + ? _value.testedViaMediaConverterId + : testedViaMediaConverterId // ignore: cast_nullable_to_non_nullable + as int?, + uplinkId: freezed == uplinkId + ? _value.uplinkId + : uplinkId // ignore: cast_nullable_to_non_nullable + as int?, + wlanId: freezed == wlanId + ? _value.wlanId + : wlanId // ignore: cast_nullable_to_non_nullable + as int?, + pmsRoomId: freezed == pmsRoomId + ? _value.pmsRoomId + : pmsRoomId // ignore: cast_nullable_to_non_nullable + as int?, + roomType: freezed == roomType + ? _value.roomType + : roomType // ignore: cast_nullable_to_non_nullable + as String?, + adminId: freezed == adminId + ? _value.adminId + : adminId // ignore: cast_nullable_to_non_nullable + as int?, + note: freezed == note + ? _value.note + : note // ignore: cast_nullable_to_non_nullable + as String?, + scratch: freezed == scratch + ? _value.scratch + : scratch // ignore: cast_nullable_to_non_nullable + as String?, + createdBy: freezed == createdBy + ? _value.createdBy + : createdBy // ignore: cast_nullable_to_non_nullable + as String?, + updatedBy: freezed == updatedBy + ? _value.updatedBy + : updatedBy // ignore: cast_nullable_to_non_nullable + as String?, + createdAt: freezed == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, hasError: null == hasError ? _value.hasError : hasError // ignore: cast_nullable_to_non_nullable @@ -178,14 +505,45 @@ abstract class _$$SpeedTestResultImplCopyWith<$Res> @override @useResult $Res call( - {double downloadSpeed, - double uploadSpeed, - double latency, - DateTime timestamp, + {int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(name: 'test_type') String? testType, + String? source, + String? destination, + int? port, + @JsonKey(name: 'iperf_protocol') String? iperfProtocol, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, + bool passed, + @JsonKey(name: 'is_applicable') bool isApplicable, + @JsonKey(name: 'initiated_at') DateTime? initiatedAt, + @JsonKey(name: 'completed_at') DateTime? completedAt, + String? raw, + @JsonKey(name: 'image_url') String? imageUrl, + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'room_type') String? roomType, + @JsonKey(name: 'admin_id') int? adminId, + String? note, + String? scratch, + @JsonKey(name: 'created_by') String? createdBy, + @JsonKey(name: 'updated_by') String? updatedBy, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, bool hasError, String? errorMessage, - String? localIpAddress, - String? serverHost}); + @JsonKey(name: 'local_ip_address') String? localIpAddress, + @JsonKey(name: 'server_host') String? serverHost}); } /// @nodoc @@ -199,32 +557,177 @@ class __$$SpeedTestResultImplCopyWithImpl<$Res> @pragma('vm:prefer-inline') @override $Res call({ - Object? downloadSpeed = null, - Object? uploadSpeed = null, - Object? latency = null, - Object? timestamp = null, + Object? id = freezed, + Object? speedTestId = freezed, + Object? testType = freezed, + Object? source = freezed, + Object? destination = freezed, + Object? port = freezed, + Object? iperfProtocol = freezed, + Object? downloadMbps = freezed, + Object? uploadMbps = freezed, + Object? rtt = freezed, + Object? jitter = freezed, + Object? packetLoss = freezed, + Object? passed = null, + Object? isApplicable = null, + Object? initiatedAt = freezed, + Object? completedAt = freezed, + Object? raw = freezed, + Object? imageUrl = freezed, + Object? accessPointId = freezed, + Object? testedViaAccessPointId = freezed, + Object? testedViaAccessPointRadioId = freezed, + Object? testedViaMediaConverterId = freezed, + Object? uplinkId = freezed, + Object? wlanId = freezed, + Object? pmsRoomId = freezed, + Object? roomType = freezed, + Object? adminId = freezed, + Object? note = freezed, + Object? scratch = freezed, + Object? createdBy = freezed, + Object? updatedBy = freezed, + Object? createdAt = freezed, + Object? updatedAt = freezed, Object? hasError = null, Object? errorMessage = freezed, Object? localIpAddress = freezed, Object? serverHost = freezed, }) { return _then(_$SpeedTestResultImpl( - downloadSpeed: null == downloadSpeed - ? _value.downloadSpeed - : downloadSpeed // ignore: cast_nullable_to_non_nullable - as double, - uploadSpeed: null == uploadSpeed - ? _value.uploadSpeed - : uploadSpeed // ignore: cast_nullable_to_non_nullable - as double, - latency: null == latency - ? _value.latency - : latency // ignore: cast_nullable_to_non_nullable - as double, - timestamp: null == timestamp - ? _value.timestamp - : timestamp // ignore: cast_nullable_to_non_nullable - as DateTime, + id: freezed == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as int?, + speedTestId: freezed == speedTestId + ? _value.speedTestId + : speedTestId // ignore: cast_nullable_to_non_nullable + as int?, + testType: freezed == testType + ? _value.testType + : testType // ignore: cast_nullable_to_non_nullable + as String?, + source: freezed == source + ? _value.source + : source // ignore: cast_nullable_to_non_nullable + as String?, + destination: freezed == destination + ? _value.destination + : destination // ignore: cast_nullable_to_non_nullable + as String?, + port: freezed == port + ? _value.port + : port // ignore: cast_nullable_to_non_nullable + as int?, + iperfProtocol: freezed == iperfProtocol + ? _value.iperfProtocol + : iperfProtocol // ignore: cast_nullable_to_non_nullable + as String?, + downloadMbps: freezed == downloadMbps + ? _value.downloadMbps + : downloadMbps // ignore: cast_nullable_to_non_nullable + as double?, + uploadMbps: freezed == uploadMbps + ? _value.uploadMbps + : uploadMbps // ignore: cast_nullable_to_non_nullable + as double?, + rtt: freezed == rtt + ? _value.rtt + : rtt // ignore: cast_nullable_to_non_nullable + as double?, + jitter: freezed == jitter + ? _value.jitter + : jitter // ignore: cast_nullable_to_non_nullable + as double?, + packetLoss: freezed == packetLoss + ? _value.packetLoss + : packetLoss // ignore: cast_nullable_to_non_nullable + as double?, + passed: null == passed + ? _value.passed + : passed // ignore: cast_nullable_to_non_nullable + as bool, + isApplicable: null == isApplicable + ? _value.isApplicable + : isApplicable // ignore: cast_nullable_to_non_nullable + as bool, + initiatedAt: freezed == initiatedAt + ? _value.initiatedAt + : initiatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + completedAt: freezed == completedAt + ? _value.completedAt + : completedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + raw: freezed == raw + ? _value.raw + : raw // ignore: cast_nullable_to_non_nullable + as String?, + imageUrl: freezed == imageUrl + ? _value.imageUrl + : imageUrl // ignore: cast_nullable_to_non_nullable + as String?, + accessPointId: freezed == accessPointId + ? _value.accessPointId + : accessPointId // ignore: cast_nullable_to_non_nullable + as int?, + testedViaAccessPointId: freezed == testedViaAccessPointId + ? _value.testedViaAccessPointId + : testedViaAccessPointId // ignore: cast_nullable_to_non_nullable + as int?, + testedViaAccessPointRadioId: freezed == testedViaAccessPointRadioId + ? _value.testedViaAccessPointRadioId + : testedViaAccessPointRadioId // ignore: cast_nullable_to_non_nullable + as int?, + testedViaMediaConverterId: freezed == testedViaMediaConverterId + ? _value.testedViaMediaConverterId + : testedViaMediaConverterId // ignore: cast_nullable_to_non_nullable + as int?, + uplinkId: freezed == uplinkId + ? _value.uplinkId + : uplinkId // ignore: cast_nullable_to_non_nullable + as int?, + wlanId: freezed == wlanId + ? _value.wlanId + : wlanId // ignore: cast_nullable_to_non_nullable + as int?, + pmsRoomId: freezed == pmsRoomId + ? _value.pmsRoomId + : pmsRoomId // ignore: cast_nullable_to_non_nullable + as int?, + roomType: freezed == roomType + ? _value.roomType + : roomType // ignore: cast_nullable_to_non_nullable + as String?, + adminId: freezed == adminId + ? _value.adminId + : adminId // ignore: cast_nullable_to_non_nullable + as int?, + note: freezed == note + ? _value.note + : note // ignore: cast_nullable_to_non_nullable + as String?, + scratch: freezed == scratch + ? _value.scratch + : scratch // ignore: cast_nullable_to_non_nullable + as String?, + createdBy: freezed == createdBy + ? _value.createdBy + : createdBy // ignore: cast_nullable_to_non_nullable + as String?, + updatedBy: freezed == updatedBy + ? _value.updatedBy + : updatedBy // ignore: cast_nullable_to_non_nullable + as String?, + createdAt: freezed == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _value.updatedAt + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, hasError: null == hasError ? _value.hasError : hasError // ignore: cast_nullable_to_non_nullable @@ -249,40 +752,156 @@ class __$$SpeedTestResultImplCopyWithImpl<$Res> @JsonSerializable() class _$SpeedTestResultImpl extends _SpeedTestResult { const _$SpeedTestResultImpl( - {required this.downloadSpeed, - required this.uploadSpeed, - required this.latency, - required this.timestamp, + {this.id, + @JsonKey(name: 'speed_test_id') this.speedTestId, + @JsonKey(name: 'test_type') this.testType, + this.source, + this.destination, + this.port, + @JsonKey(name: 'iperf_protocol') this.iperfProtocol, + @JsonKey(name: 'download_mbps') this.downloadMbps, + @JsonKey(name: 'upload_mbps') this.uploadMbps, + this.rtt, + this.jitter, + @JsonKey(name: 'packet_loss') this.packetLoss, + this.passed = false, + @JsonKey(name: 'is_applicable') this.isApplicable = true, + @JsonKey(name: 'initiated_at') this.initiatedAt, + @JsonKey(name: 'completed_at') this.completedAt, + this.raw, + @JsonKey(name: 'image_url') this.imageUrl, + @JsonKey(name: 'access_point_id') this.accessPointId, + @JsonKey(name: 'tested_via_access_point_id') this.testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + this.testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + this.testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') this.uplinkId, + @JsonKey(name: 'wlan_id') this.wlanId, + @JsonKey(name: 'pms_room_id') this.pmsRoomId, + @JsonKey(name: 'room_type') this.roomType, + @JsonKey(name: 'admin_id') this.adminId, + this.note, + this.scratch, + @JsonKey(name: 'created_by') this.createdBy, + @JsonKey(name: 'updated_by') this.updatedBy, + @JsonKey(name: 'created_at') this.createdAt, + @JsonKey(name: 'updated_at') this.updatedAt, this.hasError = false, this.errorMessage, - this.localIpAddress, - this.serverHost}) + @JsonKey(name: 'local_ip_address') this.localIpAddress, + @JsonKey(name: 'server_host') this.serverHost}) : super._(); factory _$SpeedTestResultImpl.fromJson(Map json) => _$$SpeedTestResultImplFromJson(json); @override - final double downloadSpeed; + final int? id; + @override + @JsonKey(name: 'speed_test_id') + final int? speedTestId; + @override + @JsonKey(name: 'test_type') + final String? testType; + @override + final String? source; + @override + final String? destination; + @override + final int? port; + @override + @JsonKey(name: 'iperf_protocol') + final String? iperfProtocol; + @override + @JsonKey(name: 'download_mbps') + final double? downloadMbps; + @override + @JsonKey(name: 'upload_mbps') + final double? uploadMbps; + @override + final double? rtt; + @override + final double? jitter; + @override + @JsonKey(name: 'packet_loss') + final double? packetLoss; + @override + @JsonKey() + final bool passed; + @override + @JsonKey(name: 'is_applicable') + final bool isApplicable; + @override + @JsonKey(name: 'initiated_at') + final DateTime? initiatedAt; + @override + @JsonKey(name: 'completed_at') + final DateTime? completedAt; @override - final double uploadSpeed; + final String? raw; @override - final double latency; + @JsonKey(name: 'image_url') + final String? imageUrl; @override - final DateTime timestamp; + @JsonKey(name: 'access_point_id') + final int? accessPointId; + @override + @JsonKey(name: 'tested_via_access_point_id') + final int? testedViaAccessPointId; + @override + @JsonKey(name: 'tested_via_access_point_radio_id') + final int? testedViaAccessPointRadioId; + @override + @JsonKey(name: 'tested_via_media_converter_id') + final int? testedViaMediaConverterId; + @override + @JsonKey(name: 'uplink_id') + final int? uplinkId; + @override + @JsonKey(name: 'wlan_id') + final int? wlanId; + @override + @JsonKey(name: 'pms_room_id') + final int? pmsRoomId; + @override + @JsonKey(name: 'room_type') + final String? roomType; + @override + @JsonKey(name: 'admin_id') + final int? adminId; + @override + final String? note; + @override + final String? scratch; + @override + @JsonKey(name: 'created_by') + final String? createdBy; + @override + @JsonKey(name: 'updated_by') + final String? updatedBy; + @override + @JsonKey(name: 'created_at') + final DateTime? createdAt; + @override + @JsonKey(name: 'updated_at') + final DateTime? updatedAt; +// Legacy fields for backwards compatibility @override @JsonKey() final bool hasError; @override final String? errorMessage; @override + @JsonKey(name: 'local_ip_address') final String? localIpAddress; @override + @JsonKey(name: 'server_host') final String? serverHost; @override String toString() { - return 'SpeedTestResult(downloadSpeed: $downloadSpeed, uploadSpeed: $uploadSpeed, latency: $latency, timestamp: $timestamp, hasError: $hasError, errorMessage: $errorMessage, localIpAddress: $localIpAddress, serverHost: $serverHost)'; + return 'SpeedTestResult(id: $id, speedTestId: $speedTestId, testType: $testType, source: $source, destination: $destination, port: $port, iperfProtocol: $iperfProtocol, downloadMbps: $downloadMbps, uploadMbps: $uploadMbps, rtt: $rtt, jitter: $jitter, packetLoss: $packetLoss, passed: $passed, isApplicable: $isApplicable, initiatedAt: $initiatedAt, completedAt: $completedAt, raw: $raw, imageUrl: $imageUrl, accessPointId: $accessPointId, testedViaAccessPointId: $testedViaAccessPointId, testedViaAccessPointRadioId: $testedViaAccessPointRadioId, testedViaMediaConverterId: $testedViaMediaConverterId, uplinkId: $uplinkId, wlanId: $wlanId, pmsRoomId: $pmsRoomId, roomType: $roomType, adminId: $adminId, note: $note, scratch: $scratch, createdBy: $createdBy, updatedBy: $updatedBy, createdAt: $createdAt, updatedAt: $updatedAt, hasError: $hasError, errorMessage: $errorMessage, localIpAddress: $localIpAddress, serverHost: $serverHost)'; } @override @@ -290,13 +909,64 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { return identical(this, other) || (other.runtimeType == runtimeType && other is _$SpeedTestResultImpl && - (identical(other.downloadSpeed, downloadSpeed) || - other.downloadSpeed == downloadSpeed) && - (identical(other.uploadSpeed, uploadSpeed) || - other.uploadSpeed == uploadSpeed) && - (identical(other.latency, latency) || other.latency == latency) && - (identical(other.timestamp, timestamp) || - other.timestamp == timestamp) && + (identical(other.id, id) || other.id == id) && + (identical(other.speedTestId, speedTestId) || + other.speedTestId == speedTestId) && + (identical(other.testType, testType) || + other.testType == testType) && + (identical(other.source, source) || other.source == source) && + (identical(other.destination, destination) || + other.destination == destination) && + (identical(other.port, port) || other.port == port) && + (identical(other.iperfProtocol, iperfProtocol) || + other.iperfProtocol == iperfProtocol) && + (identical(other.downloadMbps, downloadMbps) || + other.downloadMbps == downloadMbps) && + (identical(other.uploadMbps, uploadMbps) || + other.uploadMbps == uploadMbps) && + (identical(other.rtt, rtt) || other.rtt == rtt) && + (identical(other.jitter, jitter) || other.jitter == jitter) && + (identical(other.packetLoss, packetLoss) || + other.packetLoss == packetLoss) && + (identical(other.passed, passed) || other.passed == passed) && + (identical(other.isApplicable, isApplicable) || + other.isApplicable == isApplicable) && + (identical(other.initiatedAt, initiatedAt) || + other.initiatedAt == initiatedAt) && + (identical(other.completedAt, completedAt) || + other.completedAt == completedAt) && + (identical(other.raw, raw) || other.raw == raw) && + (identical(other.imageUrl, imageUrl) || + other.imageUrl == imageUrl) && + (identical(other.accessPointId, accessPointId) || + other.accessPointId == accessPointId) && + (identical(other.testedViaAccessPointId, testedViaAccessPointId) || + other.testedViaAccessPointId == testedViaAccessPointId) && + (identical(other.testedViaAccessPointRadioId, + testedViaAccessPointRadioId) || + other.testedViaAccessPointRadioId == + testedViaAccessPointRadioId) && + (identical(other.testedViaMediaConverterId, + testedViaMediaConverterId) || + other.testedViaMediaConverterId == testedViaMediaConverterId) && + (identical(other.uplinkId, uplinkId) || + other.uplinkId == uplinkId) && + (identical(other.wlanId, wlanId) || other.wlanId == wlanId) && + (identical(other.pmsRoomId, pmsRoomId) || + other.pmsRoomId == pmsRoomId) && + (identical(other.roomType, roomType) || + other.roomType == roomType) && + (identical(other.adminId, adminId) || other.adminId == adminId) && + (identical(other.note, note) || other.note == note) && + (identical(other.scratch, scratch) || other.scratch == scratch) && + (identical(other.createdBy, createdBy) || + other.createdBy == createdBy) && + (identical(other.updatedBy, updatedBy) || + other.updatedBy == updatedBy) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || + other.updatedAt == updatedAt) && (identical(other.hasError, hasError) || other.hasError == hasError) && (identical(other.errorMessage, errorMessage) || @@ -309,8 +979,46 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @JsonKey(ignore: true) @override - int get hashCode => Object.hash(runtimeType, downloadSpeed, uploadSpeed, - latency, timestamp, hasError, errorMessage, localIpAddress, serverHost); + int get hashCode => Object.hashAll([ + runtimeType, + id, + speedTestId, + testType, + source, + destination, + port, + iperfProtocol, + downloadMbps, + uploadMbps, + rtt, + jitter, + packetLoss, + passed, + isApplicable, + initiatedAt, + completedAt, + raw, + imageUrl, + accessPointId, + testedViaAccessPointId, + testedViaAccessPointRadioId, + testedViaMediaConverterId, + uplinkId, + wlanId, + pmsRoomId, + roomType, + adminId, + note, + scratch, + createdBy, + updatedBy, + createdAt, + updatedAt, + hasError, + errorMessage, + localIpAddress, + serverHost + ]); @JsonKey(ignore: true) @override @@ -323,56 +1031,260 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @optionalTypeArgs TResult when( TResult Function( - double downloadSpeed, - double uploadSpeed, - double latency, - DateTime timestamp, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(name: 'test_type') String? testType, + String? source, + String? destination, + int? port, + @JsonKey(name: 'iperf_protocol') String? iperfProtocol, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, + bool passed, + @JsonKey(name: 'is_applicable') bool isApplicable, + @JsonKey(name: 'initiated_at') DateTime? initiatedAt, + @JsonKey(name: 'completed_at') DateTime? completedAt, + String? raw, + @JsonKey(name: 'image_url') String? imageUrl, + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'room_type') String? roomType, + @JsonKey(name: 'admin_id') int? adminId, + String? note, + String? scratch, + @JsonKey(name: 'created_by') String? createdBy, + @JsonKey(name: 'updated_by') String? updatedBy, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, bool hasError, String? errorMessage, - String? localIpAddress, - String? serverHost) + @JsonKey(name: 'local_ip_address') String? localIpAddress, + @JsonKey(name: 'server_host') String? serverHost) $default, ) { - return $default(downloadSpeed, uploadSpeed, latency, timestamp, hasError, - errorMessage, localIpAddress, serverHost); + return $default( + id, + speedTestId, + testType, + source, + destination, + port, + iperfProtocol, + downloadMbps, + uploadMbps, + rtt, + jitter, + packetLoss, + passed, + isApplicable, + initiatedAt, + completedAt, + raw, + imageUrl, + accessPointId, + testedViaAccessPointId, + testedViaAccessPointRadioId, + testedViaMediaConverterId, + uplinkId, + wlanId, + pmsRoomId, + roomType, + adminId, + note, + scratch, + createdBy, + updatedBy, + createdAt, + updatedAt, + hasError, + errorMessage, + localIpAddress, + serverHost); } @override @optionalTypeArgs TResult? whenOrNull( TResult? Function( - double downloadSpeed, - double uploadSpeed, - double latency, - DateTime timestamp, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(name: 'test_type') String? testType, + String? source, + String? destination, + int? port, + @JsonKey(name: 'iperf_protocol') String? iperfProtocol, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, + bool passed, + @JsonKey(name: 'is_applicable') bool isApplicable, + @JsonKey(name: 'initiated_at') DateTime? initiatedAt, + @JsonKey(name: 'completed_at') DateTime? completedAt, + String? raw, + @JsonKey(name: 'image_url') String? imageUrl, + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'room_type') String? roomType, + @JsonKey(name: 'admin_id') int? adminId, + String? note, + String? scratch, + @JsonKey(name: 'created_by') String? createdBy, + @JsonKey(name: 'updated_by') String? updatedBy, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, bool hasError, String? errorMessage, - String? localIpAddress, - String? serverHost)? + @JsonKey(name: 'local_ip_address') String? localIpAddress, + @JsonKey(name: 'server_host') String? serverHost)? $default, ) { - return $default?.call(downloadSpeed, uploadSpeed, latency, timestamp, - hasError, errorMessage, localIpAddress, serverHost); + return $default?.call( + id, + speedTestId, + testType, + source, + destination, + port, + iperfProtocol, + downloadMbps, + uploadMbps, + rtt, + jitter, + packetLoss, + passed, + isApplicable, + initiatedAt, + completedAt, + raw, + imageUrl, + accessPointId, + testedViaAccessPointId, + testedViaAccessPointRadioId, + testedViaMediaConverterId, + uplinkId, + wlanId, + pmsRoomId, + roomType, + adminId, + note, + scratch, + createdBy, + updatedBy, + createdAt, + updatedAt, + hasError, + errorMessage, + localIpAddress, + serverHost); } @override @optionalTypeArgs TResult maybeWhen( TResult Function( - double downloadSpeed, - double uploadSpeed, - double latency, - DateTime timestamp, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(name: 'test_type') String? testType, + String? source, + String? destination, + int? port, + @JsonKey(name: 'iperf_protocol') String? iperfProtocol, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, + bool passed, + @JsonKey(name: 'is_applicable') bool isApplicable, + @JsonKey(name: 'initiated_at') DateTime? initiatedAt, + @JsonKey(name: 'completed_at') DateTime? completedAt, + String? raw, + @JsonKey(name: 'image_url') String? imageUrl, + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'room_type') String? roomType, + @JsonKey(name: 'admin_id') int? adminId, + String? note, + String? scratch, + @JsonKey(name: 'created_by') String? createdBy, + @JsonKey(name: 'updated_by') String? updatedBy, + @JsonKey(name: 'created_at') DateTime? createdAt, + @JsonKey(name: 'updated_at') DateTime? updatedAt, bool hasError, String? errorMessage, - String? localIpAddress, - String? serverHost)? + @JsonKey(name: 'local_ip_address') String? localIpAddress, + @JsonKey(name: 'server_host') String? serverHost)? $default, { required TResult orElse(), }) { if ($default != null) { - return $default(downloadSpeed, uploadSpeed, latency, timestamp, hasError, - errorMessage, localIpAddress, serverHost); + return $default( + id, + speedTestId, + testType, + source, + destination, + port, + iperfProtocol, + downloadMbps, + uploadMbps, + rtt, + jitter, + packetLoss, + passed, + isApplicable, + initiatedAt, + completedAt, + raw, + imageUrl, + accessPointId, + testedViaAccessPointId, + testedViaAccessPointRadioId, + testedViaMediaConverterId, + uplinkId, + wlanId, + pmsRoomId, + roomType, + adminId, + note, + scratch, + createdBy, + updatedBy, + createdAt, + updatedAt, + hasError, + errorMessage, + localIpAddress, + serverHost); } return orElse(); } @@ -415,34 +1327,150 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { abstract class _SpeedTestResult extends SpeedTestResult { const factory _SpeedTestResult( - {required final double downloadSpeed, - required final double uploadSpeed, - required final double latency, - required final DateTime timestamp, - final bool hasError, - final String? errorMessage, - final String? localIpAddress, - final String? serverHost}) = _$SpeedTestResultImpl; + {final int? id, + @JsonKey(name: 'speed_test_id') final int? speedTestId, + @JsonKey(name: 'test_type') final String? testType, + final String? source, + final String? destination, + final int? port, + @JsonKey(name: 'iperf_protocol') final String? iperfProtocol, + @JsonKey(name: 'download_mbps') final double? downloadMbps, + @JsonKey(name: 'upload_mbps') final double? uploadMbps, + final double? rtt, + final double? jitter, + @JsonKey(name: 'packet_loss') final double? packetLoss, + final bool passed, + @JsonKey(name: 'is_applicable') final bool isApplicable, + @JsonKey(name: 'initiated_at') final DateTime? initiatedAt, + @JsonKey(name: 'completed_at') final DateTime? completedAt, + final String? raw, + @JsonKey(name: 'image_url') final String? imageUrl, + @JsonKey(name: 'access_point_id') final int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') + final int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + final int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + final int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') final int? uplinkId, + @JsonKey(name: 'wlan_id') final int? wlanId, + @JsonKey(name: 'pms_room_id') final int? pmsRoomId, + @JsonKey(name: 'room_type') final String? roomType, + @JsonKey(name: 'admin_id') final int? adminId, + final String? note, + final String? scratch, + @JsonKey(name: 'created_by') final String? createdBy, + @JsonKey(name: 'updated_by') final String? updatedBy, + @JsonKey(name: 'created_at') final DateTime? createdAt, + @JsonKey(name: 'updated_at') final DateTime? updatedAt, + final bool hasError, + final String? errorMessage, + @JsonKey(name: 'local_ip_address') final String? localIpAddress, + @JsonKey(name: 'server_host') final String? serverHost}) = + _$SpeedTestResultImpl; const _SpeedTestResult._() : super._(); factory _SpeedTestResult.fromJson(Map json) = _$SpeedTestResultImpl.fromJson; @override - double get downloadSpeed; + int? get id; + @override + @JsonKey(name: 'speed_test_id') + int? get speedTestId; + @override + @JsonKey(name: 'test_type') + String? get testType; + @override + String? get source; + @override + String? get destination; + @override + int? get port; + @override + @JsonKey(name: 'iperf_protocol') + String? get iperfProtocol; + @override + @JsonKey(name: 'download_mbps') + double? get downloadMbps; + @override + @JsonKey(name: 'upload_mbps') + double? get uploadMbps; + @override + double? get rtt; + @override + double? get jitter; + @override + @JsonKey(name: 'packet_loss') + double? get packetLoss; + @override + bool get passed; + @override + @JsonKey(name: 'is_applicable') + bool get isApplicable; + @override + @JsonKey(name: 'initiated_at') + DateTime? get initiatedAt; + @override + @JsonKey(name: 'completed_at') + DateTime? get completedAt; + @override + String? get raw; + @override + @JsonKey(name: 'image_url') + String? get imageUrl; + @override + @JsonKey(name: 'access_point_id') + int? get accessPointId; + @override + @JsonKey(name: 'tested_via_access_point_id') + int? get testedViaAccessPointId; + @override + @JsonKey(name: 'tested_via_access_point_radio_id') + int? get testedViaAccessPointRadioId; + @override + @JsonKey(name: 'tested_via_media_converter_id') + int? get testedViaMediaConverterId; + @override + @JsonKey(name: 'uplink_id') + int? get uplinkId; + @override + @JsonKey(name: 'wlan_id') + int? get wlanId; + @override + @JsonKey(name: 'pms_room_id') + int? get pmsRoomId; + @override + @JsonKey(name: 'room_type') + String? get roomType; + @override + @JsonKey(name: 'admin_id') + int? get adminId; + @override + String? get note; + @override + String? get scratch; @override - double get uploadSpeed; + @JsonKey(name: 'created_by') + String? get createdBy; @override - double get latency; + @JsonKey(name: 'updated_by') + String? get updatedBy; @override - DateTime get timestamp; + @JsonKey(name: 'created_at') + DateTime? get createdAt; @override + @JsonKey(name: 'updated_at') + DateTime? get updatedAt; + @override // Legacy fields for backwards compatibility bool get hasError; @override String? get errorMessage; @override + @JsonKey(name: 'local_ip_address') String? get localIpAddress; @override + @JsonKey(name: 'server_host') String? get serverHost; @override @JsonKey(ignore: true) diff --git a/lib/features/speed_test/domain/entities/speed_test_result.g.dart b/lib/features/speed_test/domain/entities/speed_test_result.g.dart index 865cf42..b75fb75 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.g.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.g.dart @@ -9,10 +9,50 @@ part of 'speed_test_result.dart'; _$SpeedTestResultImpl _$$SpeedTestResultImplFromJson( Map json) => _$SpeedTestResultImpl( - downloadSpeed: (json['download_speed'] as num).toDouble(), - uploadSpeed: (json['upload_speed'] as num).toDouble(), - latency: (json['latency'] as num).toDouble(), - timestamp: DateTime.parse(json['timestamp'] as String), + id: (json['id'] as num?)?.toInt(), + speedTestId: (json['speed_test_id'] as num?)?.toInt(), + testType: json['test_type'] as String?, + source: json['source'] as String?, + destination: json['destination'] as String?, + port: (json['port'] as num?)?.toInt(), + iperfProtocol: json['iperf_protocol'] as String?, + downloadMbps: (json['download_mbps'] as num?)?.toDouble(), + uploadMbps: (json['upload_mbps'] as num?)?.toDouble(), + rtt: (json['rtt'] as num?)?.toDouble(), + jitter: (json['jitter'] as num?)?.toDouble(), + packetLoss: (json['packet_loss'] as num?)?.toDouble(), + passed: json['passed'] as bool? ?? false, + isApplicable: json['is_applicable'] as bool? ?? true, + initiatedAt: json['initiated_at'] == null + ? null + : DateTime.parse(json['initiated_at'] as String), + completedAt: json['completed_at'] == null + ? null + : DateTime.parse(json['completed_at'] as String), + raw: json['raw'] as String?, + imageUrl: json['image_url'] as String?, + accessPointId: (json['access_point_id'] as num?)?.toInt(), + testedViaAccessPointId: + (json['tested_via_access_point_id'] as num?)?.toInt(), + testedViaAccessPointRadioId: + (json['tested_via_access_point_radio_id'] as num?)?.toInt(), + testedViaMediaConverterId: + (json['tested_via_media_converter_id'] as num?)?.toInt(), + uplinkId: (json['uplink_id'] as num?)?.toInt(), + wlanId: (json['wlan_id'] as num?)?.toInt(), + pmsRoomId: (json['pms_room_id'] as num?)?.toInt(), + roomType: json['room_type'] as String?, + adminId: (json['admin_id'] as num?)?.toInt(), + note: json['note'] as String?, + scratch: json['scratch'] as String?, + createdBy: json['created_by'] as String?, + updatedBy: json['updated_by'] as String?, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), hasError: json['has_error'] as bool? ?? false, errorMessage: json['error_message'] as String?, localIpAddress: json['local_ip_address'] as String?, @@ -21,13 +61,7 @@ _$SpeedTestResultImpl _$$SpeedTestResultImplFromJson( Map _$$SpeedTestResultImplToJson( _$SpeedTestResultImpl instance) { - final val = { - 'download_speed': instance.downloadSpeed, - 'upload_speed': instance.uploadSpeed, - 'latency': instance.latency, - 'timestamp': instance.timestamp.toIso8601String(), - 'has_error': instance.hasError, - }; + final val = {}; void writeNotNull(String key, dynamic value) { if (value != null) { @@ -35,6 +69,42 @@ Map _$$SpeedTestResultImplToJson( } } + writeNotNull('id', instance.id); + writeNotNull('speed_test_id', instance.speedTestId); + writeNotNull('test_type', instance.testType); + writeNotNull('source', instance.source); + writeNotNull('destination', instance.destination); + writeNotNull('port', instance.port); + writeNotNull('iperf_protocol', instance.iperfProtocol); + writeNotNull('download_mbps', instance.downloadMbps); + writeNotNull('upload_mbps', instance.uploadMbps); + writeNotNull('rtt', instance.rtt); + writeNotNull('jitter', instance.jitter); + writeNotNull('packet_loss', instance.packetLoss); + val['passed'] = instance.passed; + val['is_applicable'] = instance.isApplicable; + writeNotNull('initiated_at', instance.initiatedAt?.toIso8601String()); + writeNotNull('completed_at', instance.completedAt?.toIso8601String()); + writeNotNull('raw', instance.raw); + writeNotNull('image_url', instance.imageUrl); + writeNotNull('access_point_id', instance.accessPointId); + writeNotNull('tested_via_access_point_id', instance.testedViaAccessPointId); + writeNotNull( + 'tested_via_access_point_radio_id', instance.testedViaAccessPointRadioId); + writeNotNull( + 'tested_via_media_converter_id', instance.testedViaMediaConverterId); + writeNotNull('uplink_id', instance.uplinkId); + writeNotNull('wlan_id', instance.wlanId); + writeNotNull('pms_room_id', instance.pmsRoomId); + writeNotNull('room_type', instance.roomType); + writeNotNull('admin_id', instance.adminId); + writeNotNull('note', instance.note); + writeNotNull('scratch', instance.scratch); + writeNotNull('created_by', instance.createdBy); + writeNotNull('updated_by', instance.updatedBy); + writeNotNull('created_at', instance.createdAt?.toIso8601String()); + writeNotNull('updated_at', instance.updatedAt?.toIso8601String()); + val['has_error'] = instance.hasError; writeNotNull('error_message', instance.errorMessage); writeNotNull('local_ip_address', instance.localIpAddress); writeNotNull('server_host', instance.serverHost); diff --git a/lib/features/speed_test/domain/entities/speed_test_with_results.dart b/lib/features/speed_test/domain/entities/speed_test_with_results.dart new file mode 100644 index 0000000..8c0822d --- /dev/null +++ b/lib/features/speed_test/domain/entities/speed_test_with_results.dart @@ -0,0 +1,69 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; + +part 'speed_test_with_results.freezed.dart'; + +/// A joined entity containing a speed test configuration with its associated results. +/// Note: This is a view model created in code, not from JSON. +@Freezed(toJson: false, fromJson: false) +class SpeedTestWithResults with _$SpeedTestWithResults { + const factory SpeedTestWithResults({ + required SpeedTestConfig config, + @Default([]) List results, + }) = _SpeedTestWithResults; + + const SpeedTestWithResults._(); + + /// Get the most recent result + SpeedTestResult? get latestResult { + if (results.isEmpty) return null; + return results.reduce((a, b) { + final aTime = a.completedAt ?? a.createdAt ?? DateTime(1970); + final bTime = b.completedAt ?? b.createdAt ?? DateTime(1970); + return aTime.isAfter(bTime) ? a : b; + }); + } + + /// Get the number of results + int get resultCount => results.length; + + /// Check if there are any results + bool get hasResults => results.isNotEmpty; + + /// Get passing results only + List get passingResults => + results.where((r) => r.passed).toList(); + + /// Get failing results only + List get failingResults => + results.where((r) => !r.passed).toList(); + + /// Calculate pass rate as percentage + double get passRate { + if (results.isEmpty) return 0.0; + return (passingResults.length / results.length) * 100; + } + + /// Check if the test is currently passing (based on latest result) + bool get isCurrentlyPassing => latestResult?.passed ?? false; + + /// Check if meets minimum download requirement + bool get meetsDownloadRequirement { + final latest = latestResult; + if (latest?.downloadMbps == null || config.minDownloadMbps == null) { + return true; + } + return latest!.downloadMbps! >= config.minDownloadMbps!; + } + + /// Check if meets minimum upload requirement + bool get meetsUploadRequirement { + final latest = latestResult; + if (latest?.uploadMbps == null || config.minUploadMbps == null) { + return true; + } + return latest!.uploadMbps! >= config.minUploadMbps!; + } +} diff --git a/lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart b/lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart new file mode 100644 index 0000000..05095ea --- /dev/null +++ b/lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart @@ -0,0 +1,271 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'speed_test_with_results.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$SpeedTestWithResults { + SpeedTestConfig get config => throw _privateConstructorUsedError; + List get results => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when( + TResult Function(SpeedTestConfig config, List results) + $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(SpeedTestConfig config, List results)? + $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen( + TResult Function(SpeedTestConfig config, List results)? + $default, { + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map( + TResult Function(_SpeedTestWithResults value) $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_SpeedTestWithResults value)? $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap( + TResult Function(_SpeedTestWithResults value)? $default, { + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $SpeedTestWithResultsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpeedTestWithResultsCopyWith<$Res> { + factory $SpeedTestWithResultsCopyWith(SpeedTestWithResults value, + $Res Function(SpeedTestWithResults) then) = + _$SpeedTestWithResultsCopyWithImpl<$Res, SpeedTestWithResults>; + @useResult + $Res call({SpeedTestConfig config, List results}); + + $SpeedTestConfigCopyWith<$Res> get config; +} + +/// @nodoc +class _$SpeedTestWithResultsCopyWithImpl<$Res, + $Val extends SpeedTestWithResults> + implements $SpeedTestWithResultsCopyWith<$Res> { + _$SpeedTestWithResultsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? config = null, + Object? results = null, + }) { + return _then(_value.copyWith( + config: null == config + ? _value.config + : config // ignore: cast_nullable_to_non_nullable + as SpeedTestConfig, + results: null == results + ? _value.results + : results // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $SpeedTestConfigCopyWith<$Res> get config { + return $SpeedTestConfigCopyWith<$Res>(_value.config, (value) { + return _then(_value.copyWith(config: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$SpeedTestWithResultsImplCopyWith<$Res> + implements $SpeedTestWithResultsCopyWith<$Res> { + factory _$$SpeedTestWithResultsImplCopyWith(_$SpeedTestWithResultsImpl value, + $Res Function(_$SpeedTestWithResultsImpl) then) = + __$$SpeedTestWithResultsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({SpeedTestConfig config, List results}); + + @override + $SpeedTestConfigCopyWith<$Res> get config; +} + +/// @nodoc +class __$$SpeedTestWithResultsImplCopyWithImpl<$Res> + extends _$SpeedTestWithResultsCopyWithImpl<$Res, _$SpeedTestWithResultsImpl> + implements _$$SpeedTestWithResultsImplCopyWith<$Res> { + __$$SpeedTestWithResultsImplCopyWithImpl(_$SpeedTestWithResultsImpl _value, + $Res Function(_$SpeedTestWithResultsImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? config = null, + Object? results = null, + }) { + return _then(_$SpeedTestWithResultsImpl( + config: null == config + ? _value.config + : config // ignore: cast_nullable_to_non_nullable + as SpeedTestConfig, + results: null == results + ? _value._results + : results // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc + +class _$SpeedTestWithResultsImpl extends _SpeedTestWithResults { + const _$SpeedTestWithResultsImpl( + {required this.config, final List results = const []}) + : _results = results, + super._(); + + @override + final SpeedTestConfig config; + final List _results; + @override + @JsonKey() + List get results { + if (_results is EqualUnmodifiableListView) return _results; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_results); + } + + @override + String toString() { + return 'SpeedTestWithResults(config: $config, results: $results)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpeedTestWithResultsImpl && + (identical(other.config, config) || other.config == config) && + const DeepCollectionEquality().equals(other._results, _results)); + } + + @override + int get hashCode => Object.hash( + runtimeType, config, const DeepCollectionEquality().hash(_results)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpeedTestWithResultsImplCopyWith<_$SpeedTestWithResultsImpl> + get copyWith => + __$$SpeedTestWithResultsImplCopyWithImpl<_$SpeedTestWithResultsImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when( + TResult Function(SpeedTestConfig config, List results) + $default, + ) { + return $default(config, results); + } + + @override + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(SpeedTestConfig config, List results)? + $default, + ) { + return $default?.call(config, results); + } + + @override + @optionalTypeArgs + TResult maybeWhen( + TResult Function(SpeedTestConfig config, List results)? + $default, { + required TResult orElse(), + }) { + if ($default != null) { + return $default(config, results); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map( + TResult Function(_SpeedTestWithResults value) $default, + ) { + return $default(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_SpeedTestWithResults value)? $default, + ) { + return $default?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap( + TResult Function(_SpeedTestWithResults value)? $default, { + required TResult orElse(), + }) { + if ($default != null) { + return $default(this); + } + return orElse(); + } +} + +abstract class _SpeedTestWithResults extends SpeedTestWithResults { + const factory _SpeedTestWithResults( + {required final SpeedTestConfig config, + final List results}) = _$SpeedTestWithResultsImpl; + const _SpeedTestWithResults._() : super._(); + + @override + SpeedTestConfig get config; + @override + List get results; + @override + @JsonKey(ignore: true) + _$$SpeedTestWithResultsImplCopyWith<_$SpeedTestWithResultsImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/features/speed_test/domain/repositories/speed_test_repository.dart b/lib/features/speed_test/domain/repositories/speed_test_repository.dart new file mode 100644 index 0000000..fd4fb7f --- /dev/null +++ b/lib/features/speed_test/domain/repositories/speed_test_repository.dart @@ -0,0 +1,55 @@ +import 'package:fpdart/fpdart.dart'; + +import 'package:rgnets_fdk/core/errors/failures.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; + +/// Repository interface for speed test configurations and results. +abstract class SpeedTestRepository { + // ============================================================================ + // Speed Test Config Operations (Read-only) + // ============================================================================ + + /// Get all speed test configurations + Future>> getSpeedTestConfigs(); + + /// Get a specific speed test configuration by ID + Future> getSpeedTestConfig(int id); + + // ============================================================================ + // Speed Test Result Operations + // ============================================================================ + + /// Get all speed test results with optional filtering + Future>> getSpeedTestResults({ + int? speedTestId, + int? accessPointId, + int? limit, + int? offset, + }); + + /// Get a specific speed test result by ID + Future> getSpeedTestResult(int id); + + /// Create a new speed test result + Future> createSpeedTestResult( + SpeedTestResult result, + ); + + /// Update an existing speed test result + Future> updateSpeedTestResult( + SpeedTestResult result, + ); + + // ============================================================================ + // Joined Operations + // ============================================================================ + + /// Get a speed test configuration with all its results + Future> getSpeedTestWithResults(int id); + + /// Get all speed test configurations with their results + Future>> + getAllSpeedTestsWithResults(); +} diff --git a/lib/features/speed_test/presentation/providers/speed_test_providers.dart b/lib/features/speed_test/presentation/providers/speed_test_providers.dart new file mode 100644 index 0000000..d303341 --- /dev/null +++ b/lib/features/speed_test/presentation/providers/speed_test_providers.dart @@ -0,0 +1,259 @@ +import 'package:logger/logger.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rgnets_fdk/core/providers/core_providers.dart'; +import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; +import 'package:rgnets_fdk/features/speed_test/data/datasources/speed_test_data_source.dart'; +import 'package:rgnets_fdk/features/speed_test/data/datasources/speed_test_websocket_data_source.dart'; +import 'package:rgnets_fdk/features/speed_test/data/repositories/speed_test_repository_impl.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/repositories/speed_test_repository.dart'; + +part 'speed_test_providers.g.dart'; + +// ============================================================================ +// Data Source Provider +// ============================================================================ + +@Riverpod(keepAlive: true) +SpeedTestDataSource speedTestDataSource(SpeedTestDataSourceRef ref) { + final webSocketService = ref.watch(webSocketServiceProvider); + return SpeedTestWebSocketDataSource( + webSocketService: webSocketService, + logger: Logger(), + ); +} + +// ============================================================================ +// Repository Provider +// ============================================================================ + +@Riverpod(keepAlive: true) +SpeedTestRepository speedTestRepository(SpeedTestRepositoryRef ref) { + final dataSource = ref.watch(speedTestDataSourceProvider); + return SpeedTestRepositoryImpl( + dataSource: dataSource, + logger: Logger(), + ); +} + +// ============================================================================ +// Speed Test Configs Provider +// ============================================================================ + +@Riverpod(keepAlive: true) +class SpeedTestConfigsNotifier extends _$SpeedTestConfigsNotifier { + Logger get _logger => ref.read(loggerProvider); + + @override + Future> build() async { + _logger.i('SpeedTestConfigsNotifier: Loading speed test configs'); + + final repository = ref.watch(speedTestRepositoryProvider); + final result = await repository.getSpeedTestConfigs(); + + return result.fold( + (failure) { + _logger.e('SpeedTestConfigsNotifier: Failed - ${failure.message}'); + throw Exception(failure.message); + }, + (configs) { + _logger.i( + 'SpeedTestConfigsNotifier: Loaded ${configs.length} configs', + ); + return configs; + }, + ); + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(speedTestRepositoryProvider); + final result = await repository.getSpeedTestConfigs(); + return result.fold( + (failure) => throw Exception(failure.message), + (configs) => configs, + ); + }); + } +} + +// ============================================================================ +// Speed Test Results Provider +// ============================================================================ + +@Riverpod(keepAlive: true) +class SpeedTestResultsNotifier extends _$SpeedTestResultsNotifier { + Logger get _logger => ref.read(loggerProvider); + + @override + Future> build({ + int? speedTestId, + int? accessPointId, + }) async { + _logger.i( + 'SpeedTestResultsNotifier: Loading results ' + '(speedTestId: $speedTestId, accessPointId: $accessPointId)', + ); + + final repository = ref.watch(speedTestRepositoryProvider); + final result = await repository.getSpeedTestResults( + speedTestId: speedTestId, + accessPointId: accessPointId, + ); + + return result.fold( + (failure) { + _logger.e('SpeedTestResultsNotifier: Failed - ${failure.message}'); + throw Exception(failure.message); + }, + (results) { + _logger.i( + 'SpeedTestResultsNotifier: Loaded ${results.length} results', + ); + return results; + }, + ); + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(speedTestRepositoryProvider); + final result = await repository.getSpeedTestResults( + speedTestId: speedTestId, + accessPointId: accessPointId, + ); + return result.fold( + (failure) => throw Exception(failure.message), + (results) => results, + ); + }); + } + + Future createResult(SpeedTestResult result) async { + final repository = ref.read(speedTestRepositoryProvider); + final createResult = await repository.createSpeedTestResult(result); + + return createResult.fold( + (failure) { + _logger.e('Failed to create result: ${failure.message}'); + return null; + }, + (created) { + // Refresh the list + refresh(); + return created; + }, + ); + } + + Future updateResult(SpeedTestResult result) async { + final repository = ref.read(speedTestRepositoryProvider); + final updateResult = await repository.updateSpeedTestResult(result); + + return updateResult.fold( + (failure) { + _logger.e('Failed to update result: ${failure.message}'); + return null; + }, + (updated) { + // Refresh the list + refresh(); + return updated; + }, + ); + } +} + +// ============================================================================ +// Speed Test With Results Provider (Joined) +// ============================================================================ + +@Riverpod(keepAlive: true) +class SpeedTestWithResultsNotifier extends _$SpeedTestWithResultsNotifier { + Logger get _logger => ref.read(loggerProvider); + + @override + Future build(int configId) async { + _logger.i('SpeedTestWithResultsNotifier: Loading config $configId'); + + final repository = ref.watch(speedTestRepositoryProvider); + final result = await repository.getSpeedTestWithResults(configId); + + return result.fold( + (failure) { + _logger.e( + 'SpeedTestWithResultsNotifier: Failed - ${failure.message}', + ); + throw Exception(failure.message); + }, + (joined) { + _logger.i( + 'SpeedTestWithResultsNotifier: Loaded config $configId ' + 'with ${joined.resultCount} results', + ); + return joined; + }, + ); + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(speedTestRepositoryProvider); + final result = await repository.getSpeedTestWithResults(configId); + return result.fold( + (failure) => throw Exception(failure.message), + (joined) => joined, + ); + }); + } +} + +// ============================================================================ +// All Speed Tests With Results Provider +// ============================================================================ + +@Riverpod(keepAlive: true) +class AllSpeedTestsWithResultsNotifier + extends _$AllSpeedTestsWithResultsNotifier { + Logger get _logger => ref.read(loggerProvider); + + @override + Future> build() async { + _logger.i('AllSpeedTestsWithResultsNotifier: Loading all speed tests'); + + final repository = ref.watch(speedTestRepositoryProvider); + final result = await repository.getAllSpeedTestsWithResults(); + + return result.fold( + (failure) { + _logger.e( + 'AllSpeedTestsWithResultsNotifier: Failed - ${failure.message}', + ); + throw Exception(failure.message); + }, + (joined) { + _logger.i( + 'AllSpeedTestsWithResultsNotifier: Loaded ${joined.length} speed tests', + ); + return joined; + }, + ); + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(speedTestRepositoryProvider); + final result = await repository.getAllSpeedTestsWithResults(); + return result.fold( + (failure) => throw Exception(failure.message), + (joined) => joined, + ); + }); + } +} diff --git a/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart b/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart new file mode 100644 index 0000000..ac308b8 --- /dev/null +++ b/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart @@ -0,0 +1,419 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'speed_test_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$speedTestDataSourceHash() => + r'04b217e04a9b8ef1dfca0744f8a1e97e12964d82'; + +/// See also [speedTestDataSource]. +@ProviderFor(speedTestDataSource) +final speedTestDataSourceProvider = Provider.internal( + speedTestDataSource, + name: r'speedTestDataSourceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$speedTestDataSourceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef SpeedTestDataSourceRef = ProviderRef; +String _$speedTestRepositoryHash() => + r'7460c9da6a8c0b1775d45e3b79a76a62b11d4c05'; + +/// See also [speedTestRepository]. +@ProviderFor(speedTestRepository) +final speedTestRepositoryProvider = Provider.internal( + speedTestRepository, + name: r'speedTestRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$speedTestRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef SpeedTestRepositoryRef = ProviderRef; +String _$speedTestConfigsNotifierHash() => + r'99fe29c99e231a1a2bf70e065b22c02a5a2806a4'; + +/// See also [SpeedTestConfigsNotifier]. +@ProviderFor(SpeedTestConfigsNotifier) +final speedTestConfigsNotifierProvider = AsyncNotifierProvider< + SpeedTestConfigsNotifier, List>.internal( + SpeedTestConfigsNotifier.new, + name: r'speedTestConfigsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$speedTestConfigsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SpeedTestConfigsNotifier = AsyncNotifier>; +String _$speedTestResultsNotifierHash() => + r'1e035a7a5d6105cc2577309ba1a1469642de508d'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$SpeedTestResultsNotifier + extends BuildlessAsyncNotifier> { + late final int? speedTestId; + late final int? accessPointId; + + FutureOr> build({ + int? speedTestId, + int? accessPointId, + }); +} + +/// See also [SpeedTestResultsNotifier]. +@ProviderFor(SpeedTestResultsNotifier) +const speedTestResultsNotifierProvider = SpeedTestResultsNotifierFamily(); + +/// See also [SpeedTestResultsNotifier]. +class SpeedTestResultsNotifierFamily + extends Family>> { + /// See also [SpeedTestResultsNotifier]. + const SpeedTestResultsNotifierFamily(); + + /// See also [SpeedTestResultsNotifier]. + SpeedTestResultsNotifierProvider call({ + int? speedTestId, + int? accessPointId, + }) { + return SpeedTestResultsNotifierProvider( + speedTestId: speedTestId, + accessPointId: accessPointId, + ); + } + + @override + SpeedTestResultsNotifierProvider getProviderOverride( + covariant SpeedTestResultsNotifierProvider provider, + ) { + return call( + speedTestId: provider.speedTestId, + accessPointId: provider.accessPointId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'speedTestResultsNotifierProvider'; +} + +/// See also [SpeedTestResultsNotifier]. +class SpeedTestResultsNotifierProvider extends AsyncNotifierProviderImpl< + SpeedTestResultsNotifier, List> { + /// See also [SpeedTestResultsNotifier]. + SpeedTestResultsNotifierProvider({ + int? speedTestId, + int? accessPointId, + }) : this._internal( + () => SpeedTestResultsNotifier() + ..speedTestId = speedTestId + ..accessPointId = accessPointId, + from: speedTestResultsNotifierProvider, + name: r'speedTestResultsNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$speedTestResultsNotifierHash, + dependencies: SpeedTestResultsNotifierFamily._dependencies, + allTransitiveDependencies: + SpeedTestResultsNotifierFamily._allTransitiveDependencies, + speedTestId: speedTestId, + accessPointId: accessPointId, + ); + + SpeedTestResultsNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.speedTestId, + required this.accessPointId, + }) : super.internal(); + + final int? speedTestId; + final int? accessPointId; + + @override + FutureOr> runNotifierBuild( + covariant SpeedTestResultsNotifier notifier, + ) { + return notifier.build( + speedTestId: speedTestId, + accessPointId: accessPointId, + ); + } + + @override + Override overrideWith(SpeedTestResultsNotifier Function() create) { + return ProviderOverride( + origin: this, + override: SpeedTestResultsNotifierProvider._internal( + () => create() + ..speedTestId = speedTestId + ..accessPointId = accessPointId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + speedTestId: speedTestId, + accessPointId: accessPointId, + ), + ); + } + + @override + AsyncNotifierProviderElement> + createElement() { + return _SpeedTestResultsNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SpeedTestResultsNotifierProvider && + other.speedTestId == speedTestId && + other.accessPointId == accessPointId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, speedTestId.hashCode); + hash = _SystemHash.combine(hash, accessPointId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin SpeedTestResultsNotifierRef + on AsyncNotifierProviderRef> { + /// The parameter `speedTestId` of this provider. + int? get speedTestId; + + /// The parameter `accessPointId` of this provider. + int? get accessPointId; +} + +class _SpeedTestResultsNotifierProviderElement + extends AsyncNotifierProviderElement> with SpeedTestResultsNotifierRef { + _SpeedTestResultsNotifierProviderElement(super.provider); + + @override + int? get speedTestId => + (origin as SpeedTestResultsNotifierProvider).speedTestId; + @override + int? get accessPointId => + (origin as SpeedTestResultsNotifierProvider).accessPointId; +} + +String _$speedTestWithResultsNotifierHash() => + r'ca7c8b8e92c543d1ca0e47ee04a15a7a328c6d40'; + +abstract class _$SpeedTestWithResultsNotifier + extends BuildlessAsyncNotifier { + late final int configId; + + FutureOr build( + int configId, + ); +} + +/// See also [SpeedTestWithResultsNotifier]. +@ProviderFor(SpeedTestWithResultsNotifier) +const speedTestWithResultsNotifierProvider = + SpeedTestWithResultsNotifierFamily(); + +/// See also [SpeedTestWithResultsNotifier]. +class SpeedTestWithResultsNotifierFamily + extends Family> { + /// See also [SpeedTestWithResultsNotifier]. + const SpeedTestWithResultsNotifierFamily(); + + /// See also [SpeedTestWithResultsNotifier]. + SpeedTestWithResultsNotifierProvider call( + int configId, + ) { + return SpeedTestWithResultsNotifierProvider( + configId, + ); + } + + @override + SpeedTestWithResultsNotifierProvider getProviderOverride( + covariant SpeedTestWithResultsNotifierProvider provider, + ) { + return call( + provider.configId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'speedTestWithResultsNotifierProvider'; +} + +/// See also [SpeedTestWithResultsNotifier]. +class SpeedTestWithResultsNotifierProvider extends AsyncNotifierProviderImpl< + SpeedTestWithResultsNotifier, SpeedTestWithResults> { + /// See also [SpeedTestWithResultsNotifier]. + SpeedTestWithResultsNotifierProvider( + int configId, + ) : this._internal( + () => SpeedTestWithResultsNotifier()..configId = configId, + from: speedTestWithResultsNotifierProvider, + name: r'speedTestWithResultsNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$speedTestWithResultsNotifierHash, + dependencies: SpeedTestWithResultsNotifierFamily._dependencies, + allTransitiveDependencies: + SpeedTestWithResultsNotifierFamily._allTransitiveDependencies, + configId: configId, + ); + + SpeedTestWithResultsNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.configId, + }) : super.internal(); + + final int configId; + + @override + FutureOr runNotifierBuild( + covariant SpeedTestWithResultsNotifier notifier, + ) { + return notifier.build( + configId, + ); + } + + @override + Override overrideWith(SpeedTestWithResultsNotifier Function() create) { + return ProviderOverride( + origin: this, + override: SpeedTestWithResultsNotifierProvider._internal( + () => create()..configId = configId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + configId: configId, + ), + ); + } + + @override + AsyncNotifierProviderElement createElement() { + return _SpeedTestWithResultsNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SpeedTestWithResultsNotifierProvider && + other.configId == configId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, configId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin SpeedTestWithResultsNotifierRef + on AsyncNotifierProviderRef { + /// The parameter `configId` of this provider. + int get configId; +} + +class _SpeedTestWithResultsNotifierProviderElement + extends AsyncNotifierProviderElement with SpeedTestWithResultsNotifierRef { + _SpeedTestWithResultsNotifierProviderElement(super.provider); + + @override + int get configId => (origin as SpeedTestWithResultsNotifierProvider).configId; +} + +String _$allSpeedTestsWithResultsNotifierHash() => + r'd773bec35269df06902eed57df641eb48a46c935'; + +/// See also [AllSpeedTestsWithResultsNotifier]. +@ProviderFor(AllSpeedTestsWithResultsNotifier) +final allSpeedTestsWithResultsNotifierProvider = AsyncNotifierProvider< + AllSpeedTestsWithResultsNotifier, List>.internal( + AllSpeedTestsWithResultsNotifier.new, + name: r'allSpeedTestsWithResultsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$allSpeedTestsWithResultsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AllSpeedTestsWithResultsNotifier + = AsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/features/speed_test/presentation/widgets/speed_test_card.dart b/lib/features/speed_test/presentation/widgets/speed_test_card.dart index c6acea4..a64adc5 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_card.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_card.dart @@ -1,20 +1,22 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; +import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; -class SpeedTestCard extends StatefulWidget { +class SpeedTestCard extends ConsumerStatefulWidget { const SpeedTestCard({super.key}); @override - State createState() => _SpeedTestCardState(); + ConsumerState createState() => _SpeedTestCardState(); } -class _SpeedTestCardState extends State { +class _SpeedTestCardState extends ConsumerState { final SpeedTestService _speedTestService = SpeedTestService(); SpeedTestStatus _status = SpeedTestStatus.idle; SpeedTestResult? _lastResult; @@ -115,19 +117,45 @@ class _SpeedTestCardState extends State { Future _showSpeedTestPopup() async { if (!mounted) return; - showDialog( + // Get available configs from provider - use first config if available (adhoc) + final configsAsync = ref.read(speedTestConfigsNotifierProvider); + final adhocConfig = configsAsync.whenOrNull( + data: (configs) => configs.isNotEmpty ? configs.first : null, + ); + + if (adhocConfig != null) { + LoggerService.info( + 'Using adhoc config: ${adhocConfig.name} (id: ${adhocConfig.id})', + tag: 'SpeedTestCard', + ); + } else { + LoggerService.info( + 'No configs available - running adhoc test without config', + tag: 'SpeedTestCard', + ); + } + + showDialog( context: context, barrierDismissible: true, builder: (BuildContext context) { return SpeedTestPopup( + cachedTest: adhocConfig, onCompleted: () async { if (mounted) { LoggerService.info( 'Speed test completed - reloading result for dashboard', tag: 'SpeedTestCard'); + + final result = _speedTestService.lastResult; setState(() { - _lastResult = _speedTestService.lastResult; + _lastResult = result; }); + + // Submit adhoc result to server if test completed successfully + if (result != null && !result.hasError) { + await _submitAdhocResult(result, adhocConfig?.id); + } } }, ); @@ -135,8 +163,81 @@ class _SpeedTestCardState extends State { ); } + /// Submit adhoc speed test result to the server + Future _submitAdhocResult(SpeedTestResult result, int? configId) async { + try { + LoggerService.info( + 'Submitting adhoc speed test result: ' + 'source=${result.localIpAddress}, ' + 'destination=${result.serverHost}, ' + 'download=${result.downloadMbps}, ' + 'upload=${result.uploadMbps}, ' + 'ping=${result.rtt}', + tag: 'SpeedTestCard', + ); + + // Check if requirements are met (for pass/fail determination) + bool passed = true; + if (configId != null) { + final configsAsync = ref.read(speedTestConfigsNotifierProvider); + final config = configsAsync.whenOrNull( + data: (configs) => configs.where((c) => c.id == configId).firstOrNull, + ); + + if (config != null) { + final downloadOk = config.minDownloadMbps == null || + (result.downloadMbps ?? 0) >= config.minDownloadMbps!; + final uploadOk = config.minUploadMbps == null || + (result.uploadMbps ?? 0) >= config.minUploadMbps!; + passed = downloadOk && uploadOk; + } + } + + // Create result with all required fields for submission + final resultToSubmit = SpeedTestResult( + speedTestId: configId, + testType: 'iperf3', + source: result.localIpAddress, + destination: result.serverHost, + port: _speedTestService.serverPort, + iperfProtocol: _speedTestService.useUdp ? 'udp' : 'tcp', + downloadMbps: result.downloadMbps, + uploadMbps: result.uploadMbps, + rtt: result.rtt, + jitter: result.jitter, + passed: passed, + completedAt: DateTime.now(), + localIpAddress: result.localIpAddress, + serverHost: result.serverHost, + ); + + // Submit via provider + final saved = await ref + .read(speedTestResultsNotifierProvider().notifier) + .createResult(resultToSubmit); + + if (saved != null) { + LoggerService.info( + 'Adhoc speed test result submitted successfully: id=${saved.id}', + tag: 'SpeedTestCard', + ); + } else { + LoggerService.warning( + 'Failed to submit adhoc speed test result', + tag: 'SpeedTestCard', + ); + } + } catch (e) { + LoggerService.error( + 'Error submitting adhoc speed test result', + error: e, + tag: 'SpeedTestCard', + ); + } + } + void _showConfigDialog() { - showDialog( + showDialog( context: context, builder: (BuildContext context) { return AlertDialog( diff --git a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart index 07c7619..cf34c84 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart @@ -7,16 +7,27 @@ import 'package:rgnets_fdk/features/speed_test/data/services/network_gateway_ser import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; class SpeedTestPopup extends StatefulWidget { + /// The speed test configuration (use this OR [speedTestWithResults]) final SpeedTestConfig? cachedTest; + + /// The joined speed test with results (use this OR [cachedTest]) + /// If provided, the config will be extracted from this + final SpeedTestWithResults? speedTestWithResults; + final VoidCallback? onCompleted; const SpeedTestPopup({ super.key, this.cachedTest, + this.speedTestWithResults, this.onCompleted, - }); + }) : assert( + cachedTest != null || speedTestWithResults != null || true, + 'Either cachedTest or speedTestWithResults can be provided, or neither for a standalone test', + ); @override State createState() => _SpeedTestPopupState(); @@ -205,12 +216,10 @@ class _SpeedTestPopupState extends State _serverHost = gatewayIp ?? 'Detecting...'; }); - String? configTarget; - final cachedTest = widget.cachedTest; - if (cachedTest != null) { - configTarget = cachedTest.target; - } + // Get target from effective config (works with both cachedTest and speedTestWithResults) + final configTarget = _getConfigTarget(); + // Run test: tries local gateway first, then falls back to config target await _speedTestService.runSpeedTestWithFallback(configTarget: configTarget); } @@ -221,18 +230,31 @@ class _SpeedTestPopupState extends State } } + /// Get the effective config from either cachedTest or speedTestWithResults + SpeedTestConfig? get _effectiveConfig { + return widget.cachedTest ?? widget.speedTestWithResults?.config; + } + double? _getMinDownload() { - return widget.cachedTest?.minDownloadMbps; + return _effectiveConfig?.minDownloadMbps; } double? _getMinUpload() { - return widget.cachedTest?.minUploadMbps; + return _effectiveConfig?.minUploadMbps; + } + + String? _getConfigTarget() { + return _effectiveConfig?.target; + } + + String? _getConfigName() { + return _effectiveConfig?.name; } void _validateTestResults() { - final cachedTest = widget.cachedTest; + final config = _effectiveConfig; - if (cachedTest == null) { + if (config == null) { _testPassed = true; return; } @@ -565,7 +587,141 @@ class _SpeedTestPopupState extends State ), ), - const SizedBox(height: 20), + const SizedBox(height: 12), + + // Requirements section (shown when config has thresholds) + if (_effectiveConfig != null && + (_getMinDownload() != null || _getMinUpload() != null)) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.assignment, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _getConfigName() ?? 'Speed Test Requirements', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + // Download requirement + Expanded( + child: Row( + children: [ + Icon( + Icons.download, + size: 14, + color: AppColors.success, + ), + const SizedBox(width: 4), + Text( + 'Min: ', + style: TextStyle( + fontSize: 11, + color: AppColors.gray400, + ), + ), + Text( + _getMinDownload() != null + ? _formatSpeed(_getMinDownload()!) + : 'None', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.gray300, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + // Upload requirement + Expanded( + child: Row( + children: [ + Icon( + Icons.upload, + size: 14, + color: AppColors.info, + ), + const SizedBox(width: 4), + Text( + 'Min: ', + style: TextStyle( + fontSize: 11, + color: AppColors.gray400, + ), + ), + Text( + _getMinUpload() != null + ? _formatSpeed(_getMinUpload()!) + : 'None', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.gray300, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ], + ), + // Server fallback info + if (_getConfigTarget() != null) ...[ + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.swap_horiz, + size: 14, + color: AppColors.gray500, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Gateway first, then ${_getConfigTarget()}', + style: TextStyle( + fontSize: 10, + color: AppColors.gray500, + fontStyle: FontStyle.italic, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 12), + ], // Speed indicators SizedBox( From f90ee8e61f08adeb0e4a0c5ed54d47a0f4a72fe6 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Mon, 19 Jan 2026 18:18:25 -0800 Subject: [PATCH 03/24] Fix json parsing --- .../speed_test_websocket_data_source.dart | 44 ++- .../domain/entities/speed_test_config.dart | 36 ++- .../entities/speed_test_config.freezed.dart | 265 ++++++++++-------- .../domain/entities/speed_test_config.g.dart | 16 +- 4 files changed, 218 insertions(+), 143 deletions(-) diff --git a/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart b/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart index 6aaf9f1..08cd11c 100644 --- a/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart +++ b/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart @@ -34,8 +34,10 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { try { final response = await _webSocketService.requestActionCable( - action: 'index', + action: 'resource_action', resourceType: _speedTestConfigResourceType, + additionalData: {'crud_action': 'index'}, + timeout: const Duration(seconds: 15), ); final data = response.payload['data']; @@ -79,9 +81,13 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { } final response = await _webSocketService.requestActionCable( - action: 'show', + action: 'resource_action', resourceType: _speedTestConfigResourceType, - additionalData: {'id': id}, + additionalData: { + 'crud_action': 'show', + 'id': id, + }, + timeout: const Duration(seconds: 15), ); final data = response.payload['data']; @@ -115,7 +121,9 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { } try { - final additionalData = {}; + final additionalData = { + 'crud_action': 'index', + }; if (speedTestId != null) additionalData['speed_test_id'] = speedTestId; if (accessPointId != null) { additionalData['access_point_id'] = accessPointId; @@ -124,9 +132,10 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { if (offset != null) additionalData['offset'] = offset; final response = await _webSocketService.requestActionCable( - action: 'index', + action: 'resource_action', resourceType: _speedTestResultResourceType, - additionalData: additionalData.isNotEmpty ? additionalData : null, + additionalData: additionalData, + timeout: const Duration(seconds: 15), ); final data = response.payload['data']; @@ -174,9 +183,13 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { } final response = await _webSocketService.requestActionCable( - action: 'show', + action: 'resource_action', resourceType: _speedTestResultResourceType, - additionalData: {'id': id}, + additionalData: { + 'crud_action': 'show', + 'id': id, + }, + timeout: const Duration(seconds: 15), ); final data = response.payload['data']; @@ -204,9 +217,12 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { ); final response = await _webSocketService.requestActionCable( - action: 'create', + action: 'create_resource', resourceType: _speedTestResultResourceType, - additionalData: jsonToSend, + additionalData: { + 'params': jsonToSend, + }, + timeout: const Duration(seconds: 15), ); LoggerService.info( @@ -242,9 +258,13 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { } final response = await _webSocketService.requestActionCable( - action: 'update', + action: 'update_resource', resourceType: _speedTestResultResourceType, - additionalData: result.toJson(), + additionalData: { + 'id': result.id, + 'params': result.toJson(), + }, + timeout: const Duration(seconds: 15), ); final data = response.payload['data']; diff --git a/lib/features/speed_test/domain/entities/speed_test_config.dart b/lib/features/speed_test/domain/entities/speed_test_config.dart index 7bf2bdb..bd5b5fe 100644 --- a/lib/features/speed_test/domain/entities/speed_test_config.dart +++ b/lib/features/speed_test/domain/entities/speed_test_config.dart @@ -3,31 +3,51 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'speed_test_config.freezed.dart'; part 'speed_test_config.g.dart'; +/// Safely converts a value to int, handling strings and nulls +int? _toInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + +/// Safely converts a value to double, handling strings and nulls +double? _toDouble(dynamic value) { + if (value == null) return null; + if (value is double) return value; + if (value is int) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; +} + @freezed class SpeedTestConfig with _$SpeedTestConfig { const factory SpeedTestConfig({ - int? id, + @JsonKey(fromJson: _toInt) int? id, String? name, @JsonKey(name: 'test_type') String? testType, String? target, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'min_download_mbps') double? minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') double? minUploadMbps, - int? period, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + double? minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) + double? minUploadMbps, + @JsonKey(fromJson: _toInt) int? period, @JsonKey(name: 'period_unit') String? periodUnit, @JsonKey(name: 'starts_at') DateTime? startsAt, @JsonKey(name: 'next_check_at') DateTime? nextCheckAt, @JsonKey(name: 'last_checked_at') DateTime? lastCheckedAt, @Default(false) bool passing, @JsonKey(name: 'last_result') String? lastResult, - @JsonKey(name: 'max_failures') int? maxFailures, + @JsonKey(name: 'max_failures', fromJson: _toInt) int? maxFailures, @JsonKey(name: 'disable_uplink_on_failure') @Default(false) bool disableUplinkOnFailure, - @JsonKey(name: 'sample_size_pct') int? sampleSizePct, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) int? sampleSizePct, @JsonKey(name: 'psk_override') String? pskOverride, - @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, diff --git a/lib/features/speed_test/domain/entities/speed_test_config.freezed.dart b/lib/features/speed_test/domain/entities/speed_test_config.freezed.dart index 7e3fdaf..dc77a23 100644 --- a/lib/features/speed_test/domain/entities/speed_test_config.freezed.dart +++ b/lib/features/speed_test/domain/entities/speed_test_config.freezed.dart @@ -20,18 +20,21 @@ SpeedTestConfig _$SpeedTestConfigFromJson(Map json) { /// @nodoc mixin _$SpeedTestConfig { + @JsonKey(fromJson: _toInt) int? get id => throw _privateConstructorUsedError; String? get name => throw _privateConstructorUsedError; @JsonKey(name: 'test_type') String? get testType => throw _privateConstructorUsedError; String? get target => throw _privateConstructorUsedError; + @JsonKey(fromJson: _toInt) int? get port => throw _privateConstructorUsedError; @JsonKey(name: 'iperf_protocol') String? get iperfProtocol => throw _privateConstructorUsedError; - @JsonKey(name: 'min_download_mbps') + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) double? get minDownloadMbps => throw _privateConstructorUsedError; - @JsonKey(name: 'min_upload_mbps') + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) double? get minUploadMbps => throw _privateConstructorUsedError; + @JsonKey(fromJson: _toInt) int? get period => throw _privateConstructorUsedError; @JsonKey(name: 'period_unit') String? get periodUnit => throw _privateConstructorUsedError; @@ -44,15 +47,15 @@ mixin _$SpeedTestConfig { bool get passing => throw _privateConstructorUsedError; @JsonKey(name: 'last_result') String? get lastResult => throw _privateConstructorUsedError; - @JsonKey(name: 'max_failures') + @JsonKey(name: 'max_failures', fromJson: _toInt) int? get maxFailures => throw _privateConstructorUsedError; @JsonKey(name: 'disable_uplink_on_failure') bool get disableUplinkOnFailure => throw _privateConstructorUsedError; - @JsonKey(name: 'sample_size_pct') + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) int? get sampleSizePct => throw _privateConstructorUsedError; @JsonKey(name: 'psk_override') String? get pskOverride => throw _privateConstructorUsedError; - @JsonKey(name: 'wlan_id') + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? get wlanId => throw _privateConstructorUsedError; String? get note => throw _privateConstructorUsedError; String? get scratch => throw _privateConstructorUsedError; @@ -67,27 +70,30 @@ mixin _$SpeedTestConfig { @optionalTypeArgs TResult when( TResult Function( - int? id, + @JsonKey(fromJson: _toInt) int? id, String? name, @JsonKey(name: 'test_type') String? testType, String? target, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'min_download_mbps') double? minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') double? minUploadMbps, - int? period, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + double? minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) + double? minUploadMbps, + @JsonKey(fromJson: _toInt) int? period, @JsonKey(name: 'period_unit') String? periodUnit, @JsonKey(name: 'starts_at') DateTime? startsAt, @JsonKey(name: 'next_check_at') DateTime? nextCheckAt, @JsonKey(name: 'last_checked_at') DateTime? lastCheckedAt, bool passing, @JsonKey(name: 'last_result') String? lastResult, - @JsonKey(name: 'max_failures') int? maxFailures, + @JsonKey(name: 'max_failures', fromJson: _toInt) int? maxFailures, @JsonKey(name: 'disable_uplink_on_failure') bool disableUplinkOnFailure, - @JsonKey(name: 'sample_size_pct') int? sampleSizePct, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) + int? sampleSizePct, @JsonKey(name: 'psk_override') String? pskOverride, - @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -100,27 +106,30 @@ mixin _$SpeedTestConfig { @optionalTypeArgs TResult? whenOrNull( TResult? Function( - int? id, + @JsonKey(fromJson: _toInt) int? id, String? name, @JsonKey(name: 'test_type') String? testType, String? target, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'min_download_mbps') double? minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') double? minUploadMbps, - int? period, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + double? minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) + double? minUploadMbps, + @JsonKey(fromJson: _toInt) int? period, @JsonKey(name: 'period_unit') String? periodUnit, @JsonKey(name: 'starts_at') DateTime? startsAt, @JsonKey(name: 'next_check_at') DateTime? nextCheckAt, @JsonKey(name: 'last_checked_at') DateTime? lastCheckedAt, bool passing, @JsonKey(name: 'last_result') String? lastResult, - @JsonKey(name: 'max_failures') int? maxFailures, + @JsonKey(name: 'max_failures', fromJson: _toInt) int? maxFailures, @JsonKey(name: 'disable_uplink_on_failure') bool disableUplinkOnFailure, - @JsonKey(name: 'sample_size_pct') int? sampleSizePct, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) + int? sampleSizePct, @JsonKey(name: 'psk_override') String? pskOverride, - @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -133,27 +142,30 @@ mixin _$SpeedTestConfig { @optionalTypeArgs TResult maybeWhen( TResult Function( - int? id, + @JsonKey(fromJson: _toInt) int? id, String? name, @JsonKey(name: 'test_type') String? testType, String? target, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'min_download_mbps') double? minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') double? minUploadMbps, - int? period, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + double? minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) + double? minUploadMbps, + @JsonKey(fromJson: _toInt) int? period, @JsonKey(name: 'period_unit') String? periodUnit, @JsonKey(name: 'starts_at') DateTime? startsAt, @JsonKey(name: 'next_check_at') DateTime? nextCheckAt, @JsonKey(name: 'last_checked_at') DateTime? lastCheckedAt, bool passing, @JsonKey(name: 'last_result') String? lastResult, - @JsonKey(name: 'max_failures') int? maxFailures, + @JsonKey(name: 'max_failures', fromJson: _toInt) int? maxFailures, @JsonKey(name: 'disable_uplink_on_failure') bool disableUplinkOnFailure, - @JsonKey(name: 'sample_size_pct') int? sampleSizePct, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) + int? sampleSizePct, @JsonKey(name: 'psk_override') String? pskOverride, - @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -193,26 +205,28 @@ abstract class $SpeedTestConfigCopyWith<$Res> { _$SpeedTestConfigCopyWithImpl<$Res, SpeedTestConfig>; @useResult $Res call( - {int? id, + {@JsonKey(fromJson: _toInt) int? id, String? name, @JsonKey(name: 'test_type') String? testType, String? target, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'min_download_mbps') double? minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') double? minUploadMbps, - int? period, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + double? minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) + double? minUploadMbps, + @JsonKey(fromJson: _toInt) int? period, @JsonKey(name: 'period_unit') String? periodUnit, @JsonKey(name: 'starts_at') DateTime? startsAt, @JsonKey(name: 'next_check_at') DateTime? nextCheckAt, @JsonKey(name: 'last_checked_at') DateTime? lastCheckedAt, bool passing, @JsonKey(name: 'last_result') String? lastResult, - @JsonKey(name: 'max_failures') int? maxFailures, + @JsonKey(name: 'max_failures', fromJson: _toInt) int? maxFailures, @JsonKey(name: 'disable_uplink_on_failure') bool disableUplinkOnFailure, - @JsonKey(name: 'sample_size_pct') int? sampleSizePct, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) int? sampleSizePct, @JsonKey(name: 'psk_override') String? pskOverride, - @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -379,26 +393,28 @@ abstract class _$$SpeedTestConfigImplCopyWith<$Res> @override @useResult $Res call( - {int? id, + {@JsonKey(fromJson: _toInt) int? id, String? name, @JsonKey(name: 'test_type') String? testType, String? target, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'min_download_mbps') double? minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') double? minUploadMbps, - int? period, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + double? minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) + double? minUploadMbps, + @JsonKey(fromJson: _toInt) int? period, @JsonKey(name: 'period_unit') String? periodUnit, @JsonKey(name: 'starts_at') DateTime? startsAt, @JsonKey(name: 'next_check_at') DateTime? nextCheckAt, @JsonKey(name: 'last_checked_at') DateTime? lastCheckedAt, bool passing, @JsonKey(name: 'last_result') String? lastResult, - @JsonKey(name: 'max_failures') int? maxFailures, + @JsonKey(name: 'max_failures', fromJson: _toInt) int? maxFailures, @JsonKey(name: 'disable_uplink_on_failure') bool disableUplinkOnFailure, - @JsonKey(name: 'sample_size_pct') int? sampleSizePct, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) int? sampleSizePct, @JsonKey(name: 'psk_override') String? pskOverride, - @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -558,27 +574,28 @@ class __$$SpeedTestConfigImplCopyWithImpl<$Res> @JsonSerializable() class _$SpeedTestConfigImpl extends _SpeedTestConfig { const _$SpeedTestConfigImpl( - {this.id, + {@JsonKey(fromJson: _toInt) this.id, this.name, @JsonKey(name: 'test_type') this.testType, this.target, - this.port, + @JsonKey(fromJson: _toInt) this.port, @JsonKey(name: 'iperf_protocol') this.iperfProtocol, - @JsonKey(name: 'min_download_mbps') this.minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') this.minUploadMbps, - this.period, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + this.minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) this.minUploadMbps, + @JsonKey(fromJson: _toInt) this.period, @JsonKey(name: 'period_unit') this.periodUnit, @JsonKey(name: 'starts_at') this.startsAt, @JsonKey(name: 'next_check_at') this.nextCheckAt, @JsonKey(name: 'last_checked_at') this.lastCheckedAt, this.passing = false, @JsonKey(name: 'last_result') this.lastResult, - @JsonKey(name: 'max_failures') this.maxFailures, + @JsonKey(name: 'max_failures', fromJson: _toInt) this.maxFailures, @JsonKey(name: 'disable_uplink_on_failure') this.disableUplinkOnFailure = false, - @JsonKey(name: 'sample_size_pct') this.sampleSizePct, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) this.sampleSizePct, @JsonKey(name: 'psk_override') this.pskOverride, - @JsonKey(name: 'wlan_id') this.wlanId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) this.wlanId, this.note, this.scratch, @JsonKey(name: 'created_by') this.createdBy, @@ -591,6 +608,7 @@ class _$SpeedTestConfigImpl extends _SpeedTestConfig { _$$SpeedTestConfigImplFromJson(json); @override + @JsonKey(fromJson: _toInt) final int? id; @override final String? name; @@ -600,17 +618,19 @@ class _$SpeedTestConfigImpl extends _SpeedTestConfig { @override final String? target; @override + @JsonKey(fromJson: _toInt) final int? port; @override @JsonKey(name: 'iperf_protocol') final String? iperfProtocol; @override - @JsonKey(name: 'min_download_mbps') + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) final double? minDownloadMbps; @override - @JsonKey(name: 'min_upload_mbps') + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) final double? minUploadMbps; @override + @JsonKey(fromJson: _toInt) final int? period; @override @JsonKey(name: 'period_unit') @@ -631,19 +651,19 @@ class _$SpeedTestConfigImpl extends _SpeedTestConfig { @JsonKey(name: 'last_result') final String? lastResult; @override - @JsonKey(name: 'max_failures') + @JsonKey(name: 'max_failures', fromJson: _toInt) final int? maxFailures; @override @JsonKey(name: 'disable_uplink_on_failure') final bool disableUplinkOnFailure; @override - @JsonKey(name: 'sample_size_pct') + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) final int? sampleSizePct; @override @JsonKey(name: 'psk_override') final String? pskOverride; @override - @JsonKey(name: 'wlan_id') + @JsonKey(name: 'wlan_id', fromJson: _toInt) final int? wlanId; @override final String? note; @@ -760,27 +780,30 @@ class _$SpeedTestConfigImpl extends _SpeedTestConfig { @optionalTypeArgs TResult when( TResult Function( - int? id, + @JsonKey(fromJson: _toInt) int? id, String? name, @JsonKey(name: 'test_type') String? testType, String? target, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'min_download_mbps') double? minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') double? minUploadMbps, - int? period, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + double? minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) + double? minUploadMbps, + @JsonKey(fromJson: _toInt) int? period, @JsonKey(name: 'period_unit') String? periodUnit, @JsonKey(name: 'starts_at') DateTime? startsAt, @JsonKey(name: 'next_check_at') DateTime? nextCheckAt, @JsonKey(name: 'last_checked_at') DateTime? lastCheckedAt, bool passing, @JsonKey(name: 'last_result') String? lastResult, - @JsonKey(name: 'max_failures') int? maxFailures, + @JsonKey(name: 'max_failures', fromJson: _toInt) int? maxFailures, @JsonKey(name: 'disable_uplink_on_failure') bool disableUplinkOnFailure, - @JsonKey(name: 'sample_size_pct') int? sampleSizePct, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) + int? sampleSizePct, @JsonKey(name: 'psk_override') String? pskOverride, - @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -822,27 +845,30 @@ class _$SpeedTestConfigImpl extends _SpeedTestConfig { @optionalTypeArgs TResult? whenOrNull( TResult? Function( - int? id, + @JsonKey(fromJson: _toInt) int? id, String? name, @JsonKey(name: 'test_type') String? testType, String? target, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'min_download_mbps') double? minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') double? minUploadMbps, - int? period, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + double? minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) + double? minUploadMbps, + @JsonKey(fromJson: _toInt) int? period, @JsonKey(name: 'period_unit') String? periodUnit, @JsonKey(name: 'starts_at') DateTime? startsAt, @JsonKey(name: 'next_check_at') DateTime? nextCheckAt, @JsonKey(name: 'last_checked_at') DateTime? lastCheckedAt, bool passing, @JsonKey(name: 'last_result') String? lastResult, - @JsonKey(name: 'max_failures') int? maxFailures, + @JsonKey(name: 'max_failures', fromJson: _toInt) int? maxFailures, @JsonKey(name: 'disable_uplink_on_failure') bool disableUplinkOnFailure, - @JsonKey(name: 'sample_size_pct') int? sampleSizePct, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) + int? sampleSizePct, @JsonKey(name: 'psk_override') String? pskOverride, - @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -884,27 +910,30 @@ class _$SpeedTestConfigImpl extends _SpeedTestConfig { @optionalTypeArgs TResult maybeWhen( TResult Function( - int? id, + @JsonKey(fromJson: _toInt) int? id, String? name, @JsonKey(name: 'test_type') String? testType, String? target, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'min_download_mbps') double? minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') double? minUploadMbps, - int? period, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + double? minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) + double? minUploadMbps, + @JsonKey(fromJson: _toInt) int? period, @JsonKey(name: 'period_unit') String? periodUnit, @JsonKey(name: 'starts_at') DateTime? startsAt, @JsonKey(name: 'next_check_at') DateTime? nextCheckAt, @JsonKey(name: 'last_checked_at') DateTime? lastCheckedAt, bool passing, @JsonKey(name: 'last_result') String? lastResult, - @JsonKey(name: 'max_failures') int? maxFailures, + @JsonKey(name: 'max_failures', fromJson: _toInt) int? maxFailures, @JsonKey(name: 'disable_uplink_on_failure') bool disableUplinkOnFailure, - @JsonKey(name: 'sample_size_pct') int? sampleSizePct, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) + int? sampleSizePct, @JsonKey(name: 'psk_override') String? pskOverride, - @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -984,40 +1013,44 @@ class _$SpeedTestConfigImpl extends _SpeedTestConfig { abstract class _SpeedTestConfig extends SpeedTestConfig { const factory _SpeedTestConfig( - {final int? id, - final String? name, - @JsonKey(name: 'test_type') final String? testType, - final String? target, - final int? port, - @JsonKey(name: 'iperf_protocol') final String? iperfProtocol, - @JsonKey(name: 'min_download_mbps') final double? minDownloadMbps, - @JsonKey(name: 'min_upload_mbps') final double? minUploadMbps, - final int? period, - @JsonKey(name: 'period_unit') final String? periodUnit, - @JsonKey(name: 'starts_at') final DateTime? startsAt, - @JsonKey(name: 'next_check_at') final DateTime? nextCheckAt, - @JsonKey(name: 'last_checked_at') final DateTime? lastCheckedAt, - final bool passing, - @JsonKey(name: 'last_result') final String? lastResult, - @JsonKey(name: 'max_failures') final int? maxFailures, - @JsonKey(name: 'disable_uplink_on_failure') - final bool disableUplinkOnFailure, - @JsonKey(name: 'sample_size_pct') final int? sampleSizePct, - @JsonKey(name: 'psk_override') final String? pskOverride, - @JsonKey(name: 'wlan_id') final int? wlanId, - final String? note, - final String? scratch, - @JsonKey(name: 'created_by') final String? createdBy, - @JsonKey(name: 'updated_by') final String? updatedBy, - @JsonKey(name: 'created_at') final DateTime? createdAt, - @JsonKey(name: 'updated_at') final DateTime? updatedAt}) = - _$SpeedTestConfigImpl; + {@JsonKey(fromJson: _toInt) final int? id, + final String? name, + @JsonKey(name: 'test_type') final String? testType, + final String? target, + @JsonKey(fromJson: _toInt) final int? port, + @JsonKey(name: 'iperf_protocol') final String? iperfProtocol, + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) + final double? minDownloadMbps, + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) + final double? minUploadMbps, + @JsonKey(fromJson: _toInt) final int? period, + @JsonKey(name: 'period_unit') final String? periodUnit, + @JsonKey(name: 'starts_at') final DateTime? startsAt, + @JsonKey(name: 'next_check_at') final DateTime? nextCheckAt, + @JsonKey(name: 'last_checked_at') final DateTime? lastCheckedAt, + final bool passing, + @JsonKey(name: 'last_result') final String? lastResult, + @JsonKey(name: 'max_failures', fromJson: _toInt) final int? maxFailures, + @JsonKey(name: 'disable_uplink_on_failure') + final bool disableUplinkOnFailure, + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) + final int? sampleSizePct, + @JsonKey(name: 'psk_override') final String? pskOverride, + @JsonKey(name: 'wlan_id', fromJson: _toInt) final int? wlanId, + final String? note, + final String? scratch, + @JsonKey(name: 'created_by') final String? createdBy, + @JsonKey(name: 'updated_by') final String? updatedBy, + @JsonKey(name: 'created_at') final DateTime? createdAt, + @JsonKey(name: 'updated_at') + final DateTime? updatedAt}) = _$SpeedTestConfigImpl; const _SpeedTestConfig._() : super._(); factory _SpeedTestConfig.fromJson(Map json) = _$SpeedTestConfigImpl.fromJson; @override + @JsonKey(fromJson: _toInt) int? get id; @override String? get name; @@ -1027,17 +1060,19 @@ abstract class _SpeedTestConfig extends SpeedTestConfig { @override String? get target; @override + @JsonKey(fromJson: _toInt) int? get port; @override @JsonKey(name: 'iperf_protocol') String? get iperfProtocol; @override - @JsonKey(name: 'min_download_mbps') + @JsonKey(name: 'min_download_mbps', fromJson: _toDouble) double? get minDownloadMbps; @override - @JsonKey(name: 'min_upload_mbps') + @JsonKey(name: 'min_upload_mbps', fromJson: _toDouble) double? get minUploadMbps; @override + @JsonKey(fromJson: _toInt) int? get period; @override @JsonKey(name: 'period_unit') @@ -1057,19 +1092,19 @@ abstract class _SpeedTestConfig extends SpeedTestConfig { @JsonKey(name: 'last_result') String? get lastResult; @override - @JsonKey(name: 'max_failures') + @JsonKey(name: 'max_failures', fromJson: _toInt) int? get maxFailures; @override @JsonKey(name: 'disable_uplink_on_failure') bool get disableUplinkOnFailure; @override - @JsonKey(name: 'sample_size_pct') + @JsonKey(name: 'sample_size_pct', fromJson: _toInt) int? get sampleSizePct; @override @JsonKey(name: 'psk_override') String? get pskOverride; @override - @JsonKey(name: 'wlan_id') + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? get wlanId; @override String? get note; diff --git a/lib/features/speed_test/domain/entities/speed_test_config.g.dart b/lib/features/speed_test/domain/entities/speed_test_config.g.dart index b927236..6e45d66 100644 --- a/lib/features/speed_test/domain/entities/speed_test_config.g.dart +++ b/lib/features/speed_test/domain/entities/speed_test_config.g.dart @@ -9,15 +9,15 @@ part of 'speed_test_config.dart'; _$SpeedTestConfigImpl _$$SpeedTestConfigImplFromJson( Map json) => _$SpeedTestConfigImpl( - id: (json['id'] as num?)?.toInt(), + id: _toInt(json['id']), name: json['name'] as String?, testType: json['test_type'] as String?, target: json['target'] as String?, - port: (json['port'] as num?)?.toInt(), + port: _toInt(json['port']), iperfProtocol: json['iperf_protocol'] as String?, - minDownloadMbps: (json['min_download_mbps'] as num?)?.toDouble(), - minUploadMbps: (json['min_upload_mbps'] as num?)?.toDouble(), - period: (json['period'] as num?)?.toInt(), + minDownloadMbps: _toDouble(json['min_download_mbps']), + minUploadMbps: _toDouble(json['min_upload_mbps']), + period: _toInt(json['period']), periodUnit: json['period_unit'] as String?, startsAt: json['starts_at'] == null ? null @@ -30,12 +30,12 @@ _$SpeedTestConfigImpl _$$SpeedTestConfigImplFromJson( : DateTime.parse(json['last_checked_at'] as String), passing: json['passing'] as bool? ?? false, lastResult: json['last_result'] as String?, - maxFailures: (json['max_failures'] as num?)?.toInt(), + maxFailures: _toInt(json['max_failures']), disableUplinkOnFailure: json['disable_uplink_on_failure'] as bool? ?? false, - sampleSizePct: (json['sample_size_pct'] as num?)?.toInt(), + sampleSizePct: _toInt(json['sample_size_pct']), pskOverride: json['psk_override'] as String?, - wlanId: (json['wlan_id'] as num?)?.toInt(), + wlanId: _toInt(json['wlan_id']), note: json['note'] as String?, scratch: json['scratch'] as String?, createdBy: json['created_by'] as String?, From 1732997c07840e2757cf886d68dce6d378d15e11 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Tue, 20 Jan 2026 09:32:41 -0800 Subject: [PATCH 04/24] Submission format --- .../presentation/widgets/speed_test_card.dart | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/features/speed_test/presentation/widgets/speed_test_card.dart b/lib/features/speed_test/presentation/widgets/speed_test_card.dart index a64adc5..0ebeda0 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_card.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_card.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; @@ -117,20 +118,18 @@ class _SpeedTestCardState extends ConsumerState { Future _showSpeedTestPopup() async { if (!mounted) return; - // Get available configs from provider - use first config if available (adhoc) - final configsAsync = ref.read(speedTestConfigsNotifierProvider); - final adhocConfig = configsAsync.whenOrNull( - data: (configs) => configs.isNotEmpty ? configs.first : null, - ); + // Get adhoc config from cache (pre-loaded at WebSocket connect) + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + final adhocConfig = cacheIntegration.getAdhocSpeedTestConfig(); if (adhocConfig != null) { LoggerService.info( - 'Using adhoc config: ${adhocConfig.name} (id: ${adhocConfig.id})', + 'Using adhoc config from cache: ${adhocConfig.name} (id: ${adhocConfig.id})', tag: 'SpeedTestCard', ); } else { LoggerService.info( - 'No configs available - running adhoc test without config', + 'No configs in cache - running adhoc test without config', tag: 'SpeedTestCard', ); } @@ -179,10 +178,9 @@ class _SpeedTestCardState extends ConsumerState { // Check if requirements are met (for pass/fail determination) bool passed = true; if (configId != null) { - final configsAsync = ref.read(speedTestConfigsNotifierProvider); - final config = configsAsync.whenOrNull( - data: (configs) => configs.where((c) => c.id == configId).firstOrNull, - ); + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + final configs = cacheIntegration.getCachedSpeedTestConfigs(); + final config = configs.where((c) => c.id == configId).firstOrNull; if (config != null) { final downloadOk = config.minDownloadMbps == null || From a8291acc8b6d0214e55f3d5ff3f88c1ccc0ab897 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Tue, 20 Jan 2026 14:58:12 -0800 Subject: [PATCH 05/24] Need guidance continue tomorrow --- flutter_01.png | Bin 0 -> 160366 bytes .../screens/device_detail_screen.dart | 18 +- .../widgets/device_speed_test_section.dart | 421 +++++++++++++++ .../domain/entities/speed_test_result.dart | 74 ++- .../entities/speed_test_result.freezed.dart | 485 ++++++++++-------- .../domain/entities/speed_test_result.g.dart | 34 +- .../presentation/widgets/speed_test_card.dart | 80 +-- .../widgets/speed_test_popup.dart | 47 +- 8 files changed, 836 insertions(+), 323 deletions(-) create mode 100644 flutter_01.png create mode 100644 lib/features/devices/presentation/widgets/device_speed_test_section.dart diff --git a/flutter_01.png b/flutter_01.png new file mode 100644 index 0000000000000000000000000000000000000000..e4687c98dde01877520587ef8a627d968bcb3691 GIT binary patch literal 160366 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 literal 0 HcmV?d00001 diff --git a/lib/features/devices/presentation/screens/device_detail_screen.dart b/lib/features/devices/presentation/screens/device_detail_screen.dart index cb08e2d..bb78e0c 100644 --- a/lib/features/devices/presentation/screens/device_detail_screen.dart +++ b/lib/features/devices/presentation/screens/device_detail_screen.dart @@ -7,6 +7,7 @@ import 'package:rgnets_fdk/features/devices/presentation/providers/devices_provi import 'package:rgnets_fdk/features/devices/presentation/screens/note_edit_screen.dart'; import 'package:rgnets_fdk/features/devices/presentation/widgets/advanced_info_section.dart'; import 'package:rgnets_fdk/features/devices/presentation/widgets/device_detail_sections.dart'; +import 'package:rgnets_fdk/features/devices/presentation/widgets/device_speed_test_section.dart'; import 'package:rgnets_fdk/features/devices/presentation/widgets/editable_note_section.dart'; import 'package:rgnets_fdk/features/devices/presentation/widgets/unified_summary_card.dart'; import 'package:rgnets_fdk/features/onboarding/presentation/widgets/onboarding_status_card.dart'; @@ -599,6 +600,13 @@ class _StatisticsTabState extends State<_StatisticsTab> child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Speed Test Section (for APs and ONTs) + if (device.type == DeviceTypes.accessPoint || + device.type == DeviceTypes.ont) ...[ + DeviceSpeedTestSection(device: device), + const SizedBox(height: 16), + ], + // Traffic Statistics SectionCard( title: 'Traffic Statistics', @@ -612,7 +620,7 @@ class _StatisticsTabState extends State<_StatisticsTab> ], ), const SizedBox(height: 16), - + // Performance Metrics SectionCard( title: 'Performance Metrics', @@ -625,7 +633,7 @@ class _StatisticsTabState extends State<_StatisticsTab> ], ), const SizedBox(height: 16), - + // Client Statistics (for Access Points) if (device.type == DeviceTypes.accessPoint) ...[ SectionCard( @@ -640,18 +648,18 @@ class _StatisticsTabState extends State<_StatisticsTab> ), const SizedBox(height: 16), ], - + const SizedBox(height: 80), // Space for bottom bar ], ), ); } - + String _formatUptime(int seconds) { final days = seconds ~/ 86400; final hours = (seconds % 86400) ~/ 3600; final minutes = (seconds % 3600) ~/ 60; - + if (days > 0) { return '${days}d ${hours}h ${minutes}m'; } else if (hours > 0) { diff --git a/lib/features/devices/presentation/widgets/device_speed_test_section.dart b/lib/features/devices/presentation/widgets/device_speed_test_section.dart new file mode 100644 index 0000000..bbba3fb --- /dev/null +++ b/lib/features/devices/presentation/widgets/device_speed_test_section.dart @@ -0,0 +1,421 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; +import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/core/theme/app_colors.dart'; +import 'package:rgnets_fdk/core/widgets/widgets.dart'; +import 'package:rgnets_fdk/features/devices/domain/constants/device_types.dart'; +import 'package:rgnets_fdk/features/devices/domain/entities/device.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; + +/// A section that displays speed test results for a specific device +/// and allows running new speed tests. +class DeviceSpeedTestSection extends ConsumerStatefulWidget { + const DeviceSpeedTestSection({ + required this.device, + super.key, + }); + + final Device device; + + @override + ConsumerState createState() => + _DeviceSpeedTestSectionState(); +} + +class _DeviceSpeedTestSectionState + extends ConsumerState { + List _deviceResults = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadDeviceResults(); + } + + /// Get prefixed device ID for speed test lookups. + /// If already prefixed (e.g., "ap_1307"), return as-is. + /// If raw (e.g., "1307"), add prefix based on device type. + String _getPrefixedDeviceId() { + final id = widget.device.id; + // Check if already prefixed + if (id.startsWith('ap_') || id.startsWith('ont_')) { + return id; + } + // Add prefix based on device type + final prefix = widget.device.type == DeviceTypes.accessPoint ? 'ap' : 'ont'; + return '${prefix}_$id'; + } + + int? _getNumericDeviceId() { + final id = widget.device.id; + final parts = id.split('_'); + final rawId = parts.length >= 2 ? parts.sublist(1).join('_') : id; + return int.tryParse(rawId); + } + + void _loadDeviceResults() { + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + final List results; + if (widget.device.type == DeviceTypes.accessPoint) { + final apId = _getNumericDeviceId(); + results = apId == null + ? [] + : cacheIntegration.getSpeedTestResultsForAccessPointId(apId); + } else { + results = cacheIntegration.getSpeedTestResultsForDevice( + _getPrefixedDeviceId(), + deviceType: widget.device.type, + ); + } + + LoggerService.info( + 'Loaded ${results.length} speed test result(s) for device ${_getPrefixedDeviceId()}', + tag: 'DeviceSpeedTestSection', + ); + + if (mounted) { + setState(() { + _deviceResults = results; + _isLoading = false; + }); + } + } + + String _formatSpeed(double speed) { + if (speed < 1000.0) { + return '${speed.toStringAsFixed(2)} Mbps'; + } else { + return '${(speed / 1000).toStringAsFixed(2)} Gbps'; + } + } + + String _getTimeAgo(DateTime timestamp) { + final now = DateTime.now(); + final diff = now.difference(timestamp); + + if (diff.inMinutes < 1) { + return 'Just now'; + } else if (diff.inHours < 1) { + return '${diff.inMinutes}m ago'; + } else if (diff.inDays < 1) { + return '${diff.inHours}h ago'; + } else if (diff.inDays < 30) { + return '${diff.inDays}d ago'; + } else { + return '${(diff.inDays / 30).floor()}mo ago'; + } + } + + Future _runSpeedTest() async { + if (!mounted) return; + + // Get adhoc config from cache + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + final adhocConfig = cacheIntegration.getAdhocSpeedTestConfig(); + + if (adhocConfig != null) { + LoggerService.info( + 'Running speed test for device ${_getPrefixedDeviceId()} with adhoc config: ${adhocConfig.name}', + tag: 'DeviceSpeedTestSection', + ); + } + + showDialog( + context: context, + barrierDismissible: true, + builder: (BuildContext context) { + return SpeedTestPopup( + cachedTest: adhocConfig, + onCompleted: () { + if (mounted) { + // Reload results after test completion + _loadDeviceResults(); + } + }, + onResultSubmitted: (result) async { + if (!result.hasError) { + await _submitDeviceResult(result); + } + }, + ); + }, + ); + } + + Future _submitDeviceResult(SpeedTestResult result) async { + try { + final prefixedId = _getPrefixedDeviceId(); + LoggerService.info( + 'Updating speed test result for device $prefixedId: ' + 'download=${result.downloadMbps}, upload=${result.uploadMbps}', + tag: 'DeviceSpeedTestSection', + ); + + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + final success = await cacheIntegration.updateDeviceSpeedTestResult( + deviceId: prefixedId, + downloadSpeed: result.downloadMbps ?? 0, + uploadSpeed: result.uploadMbps ?? 0, + latency: result.rtt ?? 0, + source: result.source, + destination: result.destination, + port: result.port, + protocol: result.iperfProtocol, + passed: result.passed, + ); + + if (success) { + LoggerService.info( + 'Speed test result updated successfully for device $prefixedId', + tag: 'DeviceSpeedTestSection', + ); + if (mounted) { + // Refresh the displayed results + _loadDeviceResults(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Speed test result submitted successfully'), + backgroundColor: AppColors.success, + duration: const Duration(seconds: 3), + ), + ); + } + } else { + LoggerService.warning( + 'Speed test submission failed for device $prefixedId - no existing result found', + tag: 'DeviceSpeedTestSection', + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Speed test submission failed - no existing result found'), + backgroundColor: AppColors.error, + duration: const Duration(seconds: 4), + ), + ); + } + } + } catch (e) { + LoggerService.error( + 'Error updating speed test result for device ${_getPrefixedDeviceId()}', + error: e, + tag: 'DeviceSpeedTestSection', + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Error submitting speed test result'), + backgroundColor: AppColors.error, + duration: const Duration(seconds: 4), + ), + ); + } + } + } + + Widget _buildResultCard(SpeedTestResult result) { + final passedColor = result.passed ? AppColors.success : AppColors.warning; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: passedColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: passedColor.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row with timestamp and status + Row( + children: [ + Icon( + result.passed ? Icons.check_circle : Icons.warning_amber, + color: passedColor, + size: 16, + ), + const SizedBox(width: 6), + Text( + result.passed ? 'PASSED' : 'BELOW THRESHOLD', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: passedColor, + ), + ), + const Spacer(), + Text( + _getTimeAgo(result.timestamp), + style: TextStyle( + fontSize: 10, + color: AppColors.gray500, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Speed metrics row + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildSpeedMetric( + 'Download', + result.downloadSpeed, + Icons.download, + AppColors.success, + ), + _buildSpeedMetric( + 'Upload', + result.uploadSpeed, + Icons.upload, + AppColors.info, + ), + _buildSpeedMetric( + 'Latency', + result.latency, + Icons.timer, + Colors.orange, + isLatency: true, + ), + ], + ), + + // Server info + if (result.destination != null || result.serverHost != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.dns, size: 12, color: AppColors.gray500), + const SizedBox(width: 4), + Text( + 'Server: ${result.destination ?? result.serverHost}', + style: TextStyle( + fontSize: 10, + color: AppColors.gray500, + fontFamily: 'monospace', + ), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _buildSpeedMetric( + String label, + double value, + IconData icon, + Color color, { + bool isLatency = false, + }) { + return Column( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(height: 2), + Text( + isLatency ? '${value.toStringAsFixed(0)} ms' : _formatSpeed(value), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: TextStyle( + fontSize: 9, + color: AppColors.gray500, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const SectionCard( + title: 'Speed Test', + children: [ + Center(child: LoadingIndicator()), + ], + ); + } + + final latestResult = + _deviceResults.isNotEmpty ? _deviceResults.first : null; + + return SectionCard( + title: 'Speed Test', + children: [ + // Show latest result if available + if (latestResult != null) ...[ + _buildResultCard(latestResult), + + // Show count of previous results + if (_deviceResults.length > 1) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + '${_deviceResults.length - 1} previous test(s) available', + style: TextStyle( + fontSize: 11, + color: AppColors.gray500, + fontStyle: FontStyle.italic, + ), + ), + ), + ] else ...[ + // No results placeholder + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + children: [ + Icon( + Icons.speed, + size: 40, + color: AppColors.gray500, + ), + const SizedBox(height: 8), + Text( + 'No speed tests run for this device', + style: TextStyle( + fontSize: 13, + color: AppColors.gray500, + ), + ), + ], + ), + ), + ], + + // Run test button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _runSpeedTest, + icon: Icon( + latestResult != null ? Icons.refresh : Icons.play_arrow, + size: 18, + ), + label: Text( + latestResult != null ? 'Run New Test' : 'Run Speed Test', + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/speed_test/domain/entities/speed_test_result.dart b/lib/features/speed_test/domain/entities/speed_test_result.dart index 120c46f..ace229b 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.dart @@ -4,38 +4,57 @@ import 'package:rgnets_fdk/core/services/logger_service.dart'; part 'speed_test_result.freezed.dart'; part 'speed_test_result.g.dart'; +/// Safely converts a value to int, handling strings and nulls +int? _toInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + +/// Safely converts a value to double, handling strings and nulls +double? _toDouble(dynamic value) { + if (value == null) return null; + if (value is double) return value; + if (value is int) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; +} + @freezed class SpeedTestResult with _$SpeedTestResult { const factory SpeedTestResult({ - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? packetLoss, @Default(false) bool passed, @JsonKey(name: 'is_applicable') @Default(true) bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -74,8 +93,9 @@ class SpeedTestResult with _$SpeedTestResult { /// Pre-process JSON to detect and correct swapped download/upload values static Map _preprocessJson(Map json) { - final download = _parseDecimal(json['download_mbps']); - final upload = _parseDecimal(json['upload_mbps']); + final normalizedJson = _normalizeTestedViaAccessPointId(json); + final download = _parseDecimal(normalizedJson['download_mbps']); + final upload = _parseDecimal(normalizedJson['upload_mbps']); if (download == null || upload == null) { return json; @@ -109,12 +129,28 @@ class SpeedTestResult with _$SpeedTestResult { ); // Create a new map with swapped values return { - ...json, + ...normalizedJson, 'download_mbps': upload, 'upload_mbps': download, }; } + return normalizedJson; + } + + static Map _normalizeTestedViaAccessPointId( + Map json, + ) { + final value = json['tested_via_access_point_id']; + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) { + return { + ...json, + 'tested_via_access_point_id': parsed, + }; + } + } return json; } diff --git a/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart b/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart index 2076854..9eedc6a 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart @@ -20,23 +20,27 @@ SpeedTestResult _$SpeedTestResultFromJson(Map json) { /// @nodoc mixin _$SpeedTestResult { + @JsonKey(fromJson: _toInt) int? get id => throw _privateConstructorUsedError; - @JsonKey(name: 'speed_test_id') + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? get speedTestId => throw _privateConstructorUsedError; @JsonKey(name: 'test_type') String? get testType => throw _privateConstructorUsedError; String? get source => throw _privateConstructorUsedError; String? get destination => throw _privateConstructorUsedError; + @JsonKey(fromJson: _toInt) int? get port => throw _privateConstructorUsedError; @JsonKey(name: 'iperf_protocol') String? get iperfProtocol => throw _privateConstructorUsedError; - @JsonKey(name: 'download_mbps') + @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? get downloadMbps => throw _privateConstructorUsedError; - @JsonKey(name: 'upload_mbps') + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? get uploadMbps => throw _privateConstructorUsedError; + @JsonKey(fromJson: _toDouble) double? get rtt => throw _privateConstructorUsedError; + @JsonKey(fromJson: _toDouble) double? get jitter => throw _privateConstructorUsedError; - @JsonKey(name: 'packet_loss') + @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? get packetLoss => throw _privateConstructorUsedError; bool get passed => throw _privateConstructorUsedError; @JsonKey(name: 'is_applicable') @@ -48,23 +52,23 @@ mixin _$SpeedTestResult { String? get raw => throw _privateConstructorUsedError; @JsonKey(name: 'image_url') String? get imageUrl => throw _privateConstructorUsedError; - @JsonKey(name: 'access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) int? get accessPointId => throw _privateConstructorUsedError; - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? get testedViaAccessPointId => throw _privateConstructorUsedError; - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? get testedViaAccessPointRadioId => throw _privateConstructorUsedError; - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? get testedViaMediaConverterId => throw _privateConstructorUsedError; - @JsonKey(name: 'uplink_id') + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? get uplinkId => throw _privateConstructorUsedError; - @JsonKey(name: 'wlan_id') + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? get wlanId => throw _privateConstructorUsedError; - @JsonKey(name: 'pms_room_id') + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? get pmsRoomId => throw _privateConstructorUsedError; @JsonKey(name: 'room_type') String? get roomType => throw _privateConstructorUsedError; - @JsonKey(name: 'admin_id') + @JsonKey(name: 'admin_id', fromJson: _toInt) int? get adminId => throw _privateConstructorUsedError; String? get note => throw _privateConstructorUsedError; String? get scratch => throw _privateConstructorUsedError; @@ -86,36 +90,40 @@ mixin _$SpeedTestResult { @optionalTypeArgs TResult when( TResult Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -132,36 +140,40 @@ mixin _$SpeedTestResult { @optionalTypeArgs TResult? whenOrNull( TResult? Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -178,36 +190,40 @@ mixin _$SpeedTestResult { @optionalTypeArgs TResult maybeWhen( TResult Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -251,35 +267,36 @@ abstract class $SpeedTestResultCopyWith<$Res> { _$SpeedTestResultCopyWithImpl<$Res, SpeedTestResult>; @useResult $Res call( - {int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + {@JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -505,35 +522,36 @@ abstract class _$$SpeedTestResultImplCopyWith<$Res> @override @useResult $Res call( - {int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + {@JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -752,35 +770,36 @@ class __$$SpeedTestResultImplCopyWithImpl<$Res> @JsonSerializable() class _$SpeedTestResultImpl extends _SpeedTestResult { const _$SpeedTestResultImpl( - {this.id, - @JsonKey(name: 'speed_test_id') this.speedTestId, + {@JsonKey(fromJson: _toInt) this.id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) this.speedTestId, @JsonKey(name: 'test_type') this.testType, this.source, this.destination, - this.port, + @JsonKey(fromJson: _toInt) this.port, @JsonKey(name: 'iperf_protocol') this.iperfProtocol, - @JsonKey(name: 'download_mbps') this.downloadMbps, - @JsonKey(name: 'upload_mbps') this.uploadMbps, - this.rtt, - this.jitter, - @JsonKey(name: 'packet_loss') this.packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) this.downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) this.uploadMbps, + @JsonKey(fromJson: _toDouble) this.rtt, + @JsonKey(fromJson: _toDouble) this.jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) this.packetLoss, this.passed = false, @JsonKey(name: 'is_applicable') this.isApplicable = true, @JsonKey(name: 'initiated_at') this.initiatedAt, @JsonKey(name: 'completed_at') this.completedAt, this.raw, @JsonKey(name: 'image_url') this.imageUrl, - @JsonKey(name: 'access_point_id') this.accessPointId, - @JsonKey(name: 'tested_via_access_point_id') this.testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) this.accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + this.testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) this.testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) this.testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') this.uplinkId, - @JsonKey(name: 'wlan_id') this.wlanId, - @JsonKey(name: 'pms_room_id') this.pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) this.uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) this.wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) this.pmsRoomId, @JsonKey(name: 'room_type') this.roomType, - @JsonKey(name: 'admin_id') this.adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) this.adminId, this.note, this.scratch, @JsonKey(name: 'created_by') this.createdBy, @@ -797,9 +816,10 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { _$$SpeedTestResultImplFromJson(json); @override + @JsonKey(fromJson: _toInt) final int? id; @override - @JsonKey(name: 'speed_test_id') + @JsonKey(name: 'speed_test_id', fromJson: _toInt) final int? speedTestId; @override @JsonKey(name: 'test_type') @@ -809,22 +829,25 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @override final String? destination; @override + @JsonKey(fromJson: _toInt) final int? port; @override @JsonKey(name: 'iperf_protocol') final String? iperfProtocol; @override - @JsonKey(name: 'download_mbps') + @JsonKey(name: 'download_mbps', fromJson: _toDouble) final double? downloadMbps; @override - @JsonKey(name: 'upload_mbps') + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) final double? uploadMbps; @override + @JsonKey(fromJson: _toDouble) final double? rtt; @override + @JsonKey(fromJson: _toDouble) final double? jitter; @override - @JsonKey(name: 'packet_loss') + @JsonKey(name: 'packet_loss', fromJson: _toDouble) final double? packetLoss; @override @JsonKey() @@ -844,31 +867,31 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @JsonKey(name: 'image_url') final String? imageUrl; @override - @JsonKey(name: 'access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) final int? accessPointId; @override - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) final int? testedViaAccessPointId; @override - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) final int? testedViaAccessPointRadioId; @override - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) final int? testedViaMediaConverterId; @override - @JsonKey(name: 'uplink_id') + @JsonKey(name: 'uplink_id', fromJson: _toInt) final int? uplinkId; @override - @JsonKey(name: 'wlan_id') + @JsonKey(name: 'wlan_id', fromJson: _toInt) final int? wlanId; @override - @JsonKey(name: 'pms_room_id') + @JsonKey(name: 'pms_room_id', fromJson: _toInt) final int? pmsRoomId; @override @JsonKey(name: 'room_type') final String? roomType; @override - @JsonKey(name: 'admin_id') + @JsonKey(name: 'admin_id', fromJson: _toInt) final int? adminId; @override final String? note; @@ -1031,36 +1054,40 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @optionalTypeArgs TResult when( TResult Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -1117,36 +1144,40 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @optionalTypeArgs TResult? whenOrNull( TResult? Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -1203,36 +1234,40 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @optionalTypeArgs TResult maybeWhen( TResult Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -1327,56 +1362,61 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { abstract class _SpeedTestResult extends SpeedTestResult { const factory _SpeedTestResult( - {final int? id, - @JsonKey(name: 'speed_test_id') final int? speedTestId, - @JsonKey(name: 'test_type') final String? testType, - final String? source, - final String? destination, - final int? port, - @JsonKey(name: 'iperf_protocol') final String? iperfProtocol, - @JsonKey(name: 'download_mbps') final double? downloadMbps, - @JsonKey(name: 'upload_mbps') final double? uploadMbps, - final double? rtt, - final double? jitter, - @JsonKey(name: 'packet_loss') final double? packetLoss, - final bool passed, - @JsonKey(name: 'is_applicable') final bool isApplicable, - @JsonKey(name: 'initiated_at') final DateTime? initiatedAt, - @JsonKey(name: 'completed_at') final DateTime? completedAt, - final String? raw, - @JsonKey(name: 'image_url') final String? imageUrl, - @JsonKey(name: 'access_point_id') final int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') - final int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') - final int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') - final int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') final int? uplinkId, - @JsonKey(name: 'wlan_id') final int? wlanId, - @JsonKey(name: 'pms_room_id') final int? pmsRoomId, - @JsonKey(name: 'room_type') final String? roomType, - @JsonKey(name: 'admin_id') final int? adminId, - final String? note, - final String? scratch, - @JsonKey(name: 'created_by') final String? createdBy, - @JsonKey(name: 'updated_by') final String? updatedBy, - @JsonKey(name: 'created_at') final DateTime? createdAt, - @JsonKey(name: 'updated_at') final DateTime? updatedAt, - final bool hasError, - final String? errorMessage, - @JsonKey(name: 'local_ip_address') final String? localIpAddress, - @JsonKey(name: 'server_host') final String? serverHost}) = - _$SpeedTestResultImpl; + {@JsonKey(fromJson: _toInt) final int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) final int? speedTestId, + @JsonKey(name: 'test_type') final String? testType, + final String? source, + final String? destination, + @JsonKey(fromJson: _toInt) final int? port, + @JsonKey(name: 'iperf_protocol') final String? iperfProtocol, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + final double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + final double? uploadMbps, + @JsonKey(fromJson: _toDouble) final double? rtt, + @JsonKey(fromJson: _toDouble) final double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + final double? packetLoss, + final bool passed, + @JsonKey(name: 'is_applicable') final bool isApplicable, + @JsonKey(name: 'initiated_at') final DateTime? initiatedAt, + @JsonKey(name: 'completed_at') final DateTime? completedAt, + final String? raw, + @JsonKey(name: 'image_url') final String? imageUrl, + @JsonKey(name: 'access_point_id', fromJson: _toInt) + final int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + final int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + final int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + final int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) final int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) final int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) final int? pmsRoomId, + @JsonKey(name: 'room_type') final String? roomType, + @JsonKey(name: 'admin_id', fromJson: _toInt) final int? adminId, + final String? note, + final String? scratch, + @JsonKey(name: 'created_by') final String? createdBy, + @JsonKey(name: 'updated_by') final String? updatedBy, + @JsonKey(name: 'created_at') final DateTime? createdAt, + @JsonKey(name: 'updated_at') final DateTime? updatedAt, + final bool hasError, + final String? errorMessage, + @JsonKey(name: 'local_ip_address') final String? localIpAddress, + @JsonKey(name: 'server_host') + final String? serverHost}) = _$SpeedTestResultImpl; const _SpeedTestResult._() : super._(); factory _SpeedTestResult.fromJson(Map json) = _$SpeedTestResultImpl.fromJson; @override + @JsonKey(fromJson: _toInt) int? get id; @override - @JsonKey(name: 'speed_test_id') + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? get speedTestId; @override @JsonKey(name: 'test_type') @@ -1386,22 +1426,25 @@ abstract class _SpeedTestResult extends SpeedTestResult { @override String? get destination; @override + @JsonKey(fromJson: _toInt) int? get port; @override @JsonKey(name: 'iperf_protocol') String? get iperfProtocol; @override - @JsonKey(name: 'download_mbps') + @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? get downloadMbps; @override - @JsonKey(name: 'upload_mbps') + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? get uploadMbps; @override + @JsonKey(fromJson: _toDouble) double? get rtt; @override + @JsonKey(fromJson: _toDouble) double? get jitter; @override - @JsonKey(name: 'packet_loss') + @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? get packetLoss; @override bool get passed; @@ -1420,31 +1463,31 @@ abstract class _SpeedTestResult extends SpeedTestResult { @JsonKey(name: 'image_url') String? get imageUrl; @override - @JsonKey(name: 'access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) int? get accessPointId; @override - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? get testedViaAccessPointId; @override - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? get testedViaAccessPointRadioId; @override - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? get testedViaMediaConverterId; @override - @JsonKey(name: 'uplink_id') + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? get uplinkId; @override - @JsonKey(name: 'wlan_id') + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? get wlanId; @override - @JsonKey(name: 'pms_room_id') + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? get pmsRoomId; @override @JsonKey(name: 'room_type') String? get roomType; @override - @JsonKey(name: 'admin_id') + @JsonKey(name: 'admin_id', fromJson: _toInt) int? get adminId; @override String? get note; diff --git a/lib/features/speed_test/domain/entities/speed_test_result.g.dart b/lib/features/speed_test/domain/entities/speed_test_result.g.dart index b75fb75..2304093 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.g.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.g.dart @@ -9,18 +9,18 @@ part of 'speed_test_result.dart'; _$SpeedTestResultImpl _$$SpeedTestResultImplFromJson( Map json) => _$SpeedTestResultImpl( - id: (json['id'] as num?)?.toInt(), - speedTestId: (json['speed_test_id'] as num?)?.toInt(), + id: _toInt(json['id']), + speedTestId: _toInt(json['speed_test_id']), testType: json['test_type'] as String?, source: json['source'] as String?, destination: json['destination'] as String?, - port: (json['port'] as num?)?.toInt(), + port: _toInt(json['port']), iperfProtocol: json['iperf_protocol'] as String?, - downloadMbps: (json['download_mbps'] as num?)?.toDouble(), - uploadMbps: (json['upload_mbps'] as num?)?.toDouble(), - rtt: (json['rtt'] as num?)?.toDouble(), - jitter: (json['jitter'] as num?)?.toDouble(), - packetLoss: (json['packet_loss'] as num?)?.toDouble(), + downloadMbps: _toDouble(json['download_mbps']), + uploadMbps: _toDouble(json['upload_mbps']), + rtt: _toDouble(json['rtt']), + jitter: _toDouble(json['jitter']), + packetLoss: _toDouble(json['packet_loss']), passed: json['passed'] as bool? ?? false, isApplicable: json['is_applicable'] as bool? ?? true, initiatedAt: json['initiated_at'] == null @@ -31,18 +31,16 @@ _$SpeedTestResultImpl _$$SpeedTestResultImplFromJson( : DateTime.parse(json['completed_at'] as String), raw: json['raw'] as String?, imageUrl: json['image_url'] as String?, - accessPointId: (json['access_point_id'] as num?)?.toInt(), - testedViaAccessPointId: - (json['tested_via_access_point_id'] as num?)?.toInt(), + accessPointId: _toInt(json['access_point_id']), + testedViaAccessPointId: _toInt(json['tested_via_access_point_id']), testedViaAccessPointRadioId: - (json['tested_via_access_point_radio_id'] as num?)?.toInt(), - testedViaMediaConverterId: - (json['tested_via_media_converter_id'] as num?)?.toInt(), - uplinkId: (json['uplink_id'] as num?)?.toInt(), - wlanId: (json['wlan_id'] as num?)?.toInt(), - pmsRoomId: (json['pms_room_id'] as num?)?.toInt(), + _toInt(json['tested_via_access_point_radio_id']), + testedViaMediaConverterId: _toInt(json['tested_via_media_converter_id']), + uplinkId: _toInt(json['uplink_id']), + wlanId: _toInt(json['wlan_id']), + pmsRoomId: _toInt(json['pms_room_id']), roomType: json['room_type'] as String?, - adminId: (json['admin_id'] as num?)?.toInt(), + adminId: _toInt(json['admin_id']), note: json['note'] as String?, scratch: json['scratch'] as String?, createdBy: json['created_by'] as String?, diff --git a/lib/features/speed_test/presentation/widgets/speed_test_card.dart b/lib/features/speed_test/presentation/widgets/speed_test_card.dart index 0ebeda0..df6c4e0 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_card.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_card.dart @@ -2,12 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; -import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; -import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; class SpeedTestCard extends ConsumerStatefulWidget { @@ -140,21 +139,21 @@ class _SpeedTestCardState extends ConsumerState { builder: (BuildContext context) { return SpeedTestPopup( cachedTest: adhocConfig, - onCompleted: () async { + onCompleted: () { if (mounted) { LoggerService.info( - 'Speed test completed - reloading result for dashboard', - tag: 'SpeedTestCard'); - + 'Speed test completed - reloading result for dashboard', + tag: 'SpeedTestCard', + ); final result = _speedTestService.lastResult; setState(() { _lastResult = result; }); - - // Submit adhoc result to server if test completed successfully - if (result != null && !result.hasError) { - await _submitAdhocResult(result, adhocConfig?.id); - } + } + }, + onResultSubmitted: (result) async { + if (!result.hasError) { + await _submitAdhocResult(result); } }, ); @@ -162,61 +161,34 @@ class _SpeedTestCardState extends ConsumerState { ); } - /// Submit adhoc speed test result to the server - Future _submitAdhocResult(SpeedTestResult result, int? configId) async { + /// Submit adhoc speed test result to the server via WebSocket cache integration + Future _submitAdhocResult(SpeedTestResult result) async { try { LoggerService.info( 'Submitting adhoc speed test result: ' - 'source=${result.localIpAddress}, ' - 'destination=${result.serverHost}, ' + 'source=${result.source}, ' + 'destination=${result.destination}, ' 'download=${result.downloadMbps}, ' 'upload=${result.uploadMbps}, ' 'ping=${result.rtt}', tag: 'SpeedTestCard', ); - // Check if requirements are met (for pass/fail determination) - bool passed = true; - if (configId != null) { - final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); - final configs = cacheIntegration.getCachedSpeedTestConfigs(); - final config = configs.where((c) => c.id == configId).firstOrNull; - - if (config != null) { - final downloadOk = config.minDownloadMbps == null || - (result.downloadMbps ?? 0) >= config.minDownloadMbps!; - final uploadOk = config.minUploadMbps == null || - (result.uploadMbps ?? 0) >= config.minUploadMbps!; - passed = downloadOk && uploadOk; - } - } - - // Create result with all required fields for submission - final resultToSubmit = SpeedTestResult( - speedTestId: configId, - testType: 'iperf3', - source: result.localIpAddress, - destination: result.serverHost, - port: _speedTestService.serverPort, - iperfProtocol: _speedTestService.useUdp ? 'udp' : 'tcp', - downloadMbps: result.downloadMbps, - uploadMbps: result.uploadMbps, - rtt: result.rtt, - jitter: result.jitter, - passed: passed, - completedAt: DateTime.now(), - localIpAddress: result.localIpAddress, - serverHost: result.serverHost, + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + final success = await cacheIntegration.createAdhocSpeedTestResult( + downloadSpeed: result.downloadMbps ?? 0, + uploadSpeed: result.uploadMbps ?? 0, + latency: result.rtt ?? 0, + source: result.source, + destination: result.destination, + port: result.port, + protocol: result.iperfProtocol, + passed: result.passed, ); - // Submit via provider - final saved = await ref - .read(speedTestResultsNotifierProvider().notifier) - .createResult(resultToSubmit); - - if (saved != null) { + if (success) { LoggerService.info( - 'Adhoc speed test result submitted successfully: id=${saved.id}', + 'Adhoc speed test result submitted successfully', tag: 'SpeedTestCard', ); } else { diff --git a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart index cf34c84..7efa500 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart @@ -19,11 +19,15 @@ class SpeedTestPopup extends StatefulWidget { final VoidCallback? onCompleted; + /// Callback when result should be submitted (auto-called when test passes) + final void Function(SpeedTestResult result)? onResultSubmitted; + const SpeedTestPopup({ super.key, this.cachedTest, this.speedTestWithResults, this.onCompleted, + this.onResultSubmitted, }) : assert( cachedTest != null || speedTestWithResults != null || true, 'Either cachedTest or speedTestWithResults can be provided, or neither for a standalone test', @@ -256,16 +260,47 @@ class _SpeedTestPopupState extends State if (config == null) { _testPassed = true; - return; + } else { + final minDownload = _getMinDownload(); + final minUpload = _getMinUpload(); + + final downloadPassed = minDownload == null || _downloadSpeed >= minDownload; + final uploadPassed = minUpload == null || _uploadSpeed >= minUpload; + + _testPassed = downloadPassed && uploadPassed; } - final minDownload = _getMinDownload(); - final minUpload = _getMinUpload(); + // Auto-submit result when test completes (passed or failed) + if (widget.onResultSubmitted != null) { + _submitResult(); + } + } + + /// Submit the test result via callback + void _submitResult() { + final result = SpeedTestResult( + downloadMbps: _downloadSpeed, + uploadMbps: _uploadSpeed, + rtt: _latency, + localIpAddress: _localIp, + serverHost: _serverHost, + speedTestId: _effectiveConfig?.id, + passed: _testPassed, + completedAt: DateTime.now(), + testType: 'iperf3', + source: _localIp, + destination: _serverHost, + port: _speedTestService.serverPort, + iperfProtocol: _speedTestService.useUdp ? 'udp' : 'tcp', + ); - final downloadPassed = minDownload == null || _downloadSpeed >= minDownload; - final uploadPassed = minUpload == null || _uploadSpeed >= minUpload; + LoggerService.info( + 'SpeedTestPopup: Auto-submitting result - passed=$_testPassed, ' + 'download=$_downloadSpeed, upload=$_uploadSpeed', + tag: 'SpeedTestPopup', + ); - _testPassed = downloadPassed && uploadPassed; + widget.onResultSubmitted?.call(result); } @override From 96e9c578dedf0eca7b25f2cc69cc5b370a5f8053 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Tue, 20 Jan 2026 15:45:31 -0800 Subject: [PATCH 06/24] Changed speed test design pattern to utilize riverpod --- .../data/services/speed_test_run_state.dart | 0 .../data/services/speed_test_service.dart | 12 +- .../providers/speed_test_providers.dart | 219 +++++ .../providers/speed_test_providers.g.dart | 17 + .../state/speed_test_run_state.dart | 63 ++ .../state/speed_test_run_state.freezed.dart | 866 ++++++++++++++++++ .../presentation/widgets/speed_test_card.dart | 223 ++--- .../widgets/speed_test_popup.dart | 415 +++------ 8 files changed, 1384 insertions(+), 431 deletions(-) create mode 100644 lib/features/speed_test/data/services/speed_test_run_state.dart create mode 100644 lib/features/speed_test/presentation/state/speed_test_run_state.dart create mode 100644 lib/features/speed_test/presentation/state/speed_test_run_state.freezed.dart diff --git a/lib/features/speed_test/data/services/speed_test_run_state.dart b/lib/features/speed_test/data/services/speed_test_run_state.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/features/speed_test/data/services/speed_test_service.dart b/lib/features/speed_test/data/services/speed_test_service.dart index 0e94fc4..13a149f 100644 --- a/lib/features/speed_test/data/services/speed_test_service.dart +++ b/lib/features/speed_test/data/services/speed_test_service.dart @@ -11,12 +11,13 @@ import 'package:shared_preferences/shared_preferences.dart'; /// Main orchestrator service for speed testing class SpeedTestService { - static final SpeedTestService _instance = SpeedTestService._internal(); - factory SpeedTestService() => _instance; - SpeedTestService._internal(); + /// Regular constructor - each notifier owns its own instance + SpeedTestService() + : _iperf3Service = Iperf3Service(), + _gatewayService = NetworkGatewayService(); - final Iperf3Service _iperf3Service = Iperf3Service(); - final NetworkGatewayService _gatewayService = NetworkGatewayService(); + final Iperf3Service _iperf3Service; + final NetworkGatewayService _gatewayService; // Configuration String _serverHost = ''; @@ -562,6 +563,7 @@ class SpeedTestService { return const JsonCodec().encode(map); } + /// Ensure dispose method exists to clean up streams void dispose() { _progressSubscription?.cancel(); _statusController.close(); diff --git a/lib/features/speed_test/presentation/providers/speed_test_providers.dart b/lib/features/speed_test/presentation/providers/speed_test_providers.dart index d303341..fd8790b 100644 --- a/lib/features/speed_test/presentation/providers/speed_test_providers.dart +++ b/lib/features/speed_test/presentation/providers/speed_test_providers.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:rgnets_fdk/core/providers/core_providers.dart'; @@ -5,10 +8,14 @@ import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/features/speed_test/data/datasources/speed_test_data_source.dart'; import 'package:rgnets_fdk/features/speed_test/data/datasources/speed_test_websocket_data_source.dart'; import 'package:rgnets_fdk/features/speed_test/data/repositories/speed_test_repository_impl.dart'; +import 'package:rgnets_fdk/features/speed_test/data/services/network_gateway_service.dart'; +import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; import 'package:rgnets_fdk/features/speed_test/domain/repositories/speed_test_repository.dart'; +import 'package:rgnets_fdk/features/speed_test/presentation/state/speed_test_run_state.dart'; part 'speed_test_providers.g.dart'; @@ -257,3 +264,215 @@ class AllSpeedTestsWithResultsNotifier }); } } + +// ============================================================================ +// Speed Test Run Notifier (for running tests via Riverpod) +// ============================================================================ + +@Riverpod(keepAlive: true) +class SpeedTestRunNotifier extends _$SpeedTestRunNotifier { + SpeedTestService? _service; + StreamSubscription? _resultSub; + StreamSubscription? _statusSub; + StreamSubscription? _progressSub; + StreamSubscription? _messageSub; + + @override + SpeedTestRunState build() { + ref.onDispose(() { + _resultSub?.cancel(); + _statusSub?.cancel(); + _progressSub?.cancel(); + _messageSub?.cancel(); + _service?.dispose(); + }); + return const SpeedTestRunState(); + } + + /// Idempotent initialization - safe to call multiple times + Future initialize() async { + if (state.isInitialized) return; + + _service = SpeedTestService(); + await _service!.initialize(); + _subscribeToStreams(); + await _syncNetworkInfo(); + _syncConfigFromService(); + state = state.copyWith(isInitialized: true); + } + + void _subscribeToStreams() { + // Status stream + _statusSub = _service!.statusStream.listen((status) { + state = state.copyWith(executionStatus: status); + }); + + // Progress stream + _progressSub = _service!.progressStream.listen((progress) { + state = state.copyWith(progress: progress); + }); + + // Status message stream + _messageSub = _service!.statusMessageStream.listen((message) { + state = state.copyWith(statusMessage: message); + }); + + // Result stream + _resultSub = _service!.resultStream.listen((result) { + state = state.copyWith( + downloadSpeed: result.downloadMbps ?? state.downloadSpeed, + uploadSpeed: result.uploadMbps ?? state.uploadSpeed, + latency: (result.rtt ?? 0) > 0 ? result.rtt! : state.latency, + completedResult: result, + serverHost: result.serverHost ?? state.serverHost, + errorMessage: result.hasError ? result.errorMessage : null, + ); + + // Auto-validate when result comes in + if (state.config != null) { + final passed = _validateResult(state.config!); + state = state.copyWith(testPassed: passed); + } + }); + } + + Future _syncNetworkInfo() async { + final networkService = NetworkGatewayService(); + final localIp = await networkService.getWifiIP(); + final gatewayIp = await networkService.getWifiGateway(); + state = state.copyWith( + localIpAddress: localIp, + gatewayAddress: gatewayIp, + ); + } + + void _syncConfigFromService() { + if (_service == null) return; + state = state.copyWith( + serverHost: _service!.serverHost, + serverPort: _service!.serverPort, + testDuration: _service!.testDuration, + bandwidthMbps: _service!.bandwidthMbps, + parallelStreams: _service!.parallelStreams, + useUdp: _service!.useUdp, + ); + } + + Future startTest({ + SpeedTestConfig? config, + String? configTarget, + }) async { + if (!state.isInitialized) await initialize(); + + // Reset for new test + state = state.copyWith( + config: config, + downloadSpeed: 0, + uploadSpeed: 0, + latency: 0, + progress: 0, + errorMessage: null, + statusMessage: null, + testPassed: null, + completedResult: null, + ); + + final target = configTarget ?? config?.target; + + try { + await _service!.runSpeedTestWithFallback(configTarget: target); + } catch (e) { + state = state.copyWith(errorMessage: e.toString()); + } + } + + Future cancelTest() async { + await _service?.cancelTest(); + } + + bool _validateResult(SpeedTestConfig config) { + final minDown = config.minDownloadMbps ?? 0; + final minUp = config.minUploadMbps ?? 0; + return state.downloadSpeed >= minDown && state.uploadSpeed >= minUp; + } + + void updateConfiguration({ + bool? useUdp, + int? testDuration, + int? bandwidthMbps, + int? parallelStreams, + }) { + if (!state.isInitialized) return; + _service!.updateConfiguration( + useUdp: useUdp, + testDuration: testDuration, + bandwidthMbps: bandwidthMbps, + parallelStreams: parallelStreams, + ); + _syncConfigFromService(); + } + + /// Submit result to API (for config-based tests) + /// Returns true if submission succeeded + Future submitResult({int? accessPointId}) async { + if (state.completedResult == null) return false; + + final result = state.completedResult!.copyWith( + speedTestId: state.config?.id, + passed: state.testPassed ?? false, + accessPointId: accessPointId, + port: state.serverPort, + iperfProtocol: state.useUdp ? 'udp' : 'tcp', + ); + + try { + await ref + .read(speedTestResultsNotifierProvider( + speedTestId: state.config?.id, + accessPointId: accessPointId, + ).notifier) + .createResult(result); + return true; + } catch (e) { + state = state.copyWith(errorMessage: 'Submission failed: $e'); + return false; + } + } + + /// Submit adhoc result via WebSocket (for card-based adhoc tests) + /// Returns true if submission succeeded + Future submitAdhocResult() async { + if (state.completedResult == null) return false; + + final result = state.completedResult!; + try { + await ref.read(webSocketCacheIntegrationProvider).createAdhocSpeedTestResult( + downloadSpeed: result.downloadMbps ?? 0, + uploadSpeed: result.uploadMbps ?? 0, + latency: result.rtt ?? 0, + source: result.source, + destination: result.destination, + ); + return true; + } catch (e) { + state = state.copyWith(errorMessage: 'Submission failed: $e'); + return false; + } + } + + void reset() { + if (!state.isInitialized) return; + state = state.copyWith( + executionStatus: SpeedTestStatus.idle, + progress: 0, + statusMessage: null, + downloadSpeed: 0, + uploadSpeed: 0, + latency: 0, + errorMessage: null, + testPassed: null, + completedResult: null, + config: null, + ); + } +} diff --git a/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart b/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart index ac308b8..514ab6d 100644 --- a/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart +++ b/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart @@ -415,5 +415,22 @@ final allSpeedTestsWithResultsNotifierProvider = AsyncNotifierProvider< typedef _$AllSpeedTestsWithResultsNotifier = AsyncNotifier>; +String _$speedTestRunNotifierHash() => + r'76bfa7b486be1d8d9b2e3ed1c36b42f6ed9676b7'; + +/// See also [SpeedTestRunNotifier]. +@ProviderFor(SpeedTestRunNotifier) +final speedTestRunNotifierProvider = + NotifierProvider.internal( + SpeedTestRunNotifier.new, + name: r'speedTestRunNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$speedTestRunNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SpeedTestRunNotifier = Notifier; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/features/speed_test/presentation/state/speed_test_run_state.dart b/lib/features/speed_test/presentation/state/speed_test_run_state.dart new file mode 100644 index 0000000..8787f0e --- /dev/null +++ b/lib/features/speed_test/presentation/state/speed_test_run_state.dart @@ -0,0 +1,63 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; + +part 'speed_test_run_state.freezed.dart'; + +@freezed +class SpeedTestRunState with _$SpeedTestRunState { + const factory SpeedTestRunState({ + // Execution status (idle, running, completed, error) + @Default(SpeedTestStatus.idle) SpeedTestStatus executionStatus, + + // Progress (0-100) + @Default(0.0) double progress, + + // Status message (for UI display) + String? statusMessage, + + // Result data (from result stream) + @Default(0.0) double downloadSpeed, + @Default(0.0) double uploadSpeed, + @Default(0.0) double latency, + + // Validation status: null = not run, true = passed, false = failed + bool? testPassed, + + // Error state + String? errorMessage, + + // Network info + String? localIpAddress, + String? gatewayAddress, + + // Server configuration + @Default('') String serverHost, + @Default(5201) int serverPort, + + // Test configuration + @Default(10) int testDuration, + @Default(0) int bandwidthMbps, + @Default(1) int parallelStreams, + @Default(false) bool useUdp, + + // Full result object (for submission) + SpeedTestResult? completedResult, + SpeedTestConfig? config, + + // Initialization flag + @Default(false) bool isInitialized, + }) = _SpeedTestRunState; + + const SpeedTestRunState._(); + + /// Derived validation status: not run, passed, or failed + String get validationStatus { + if (testPassed == null) return 'not run'; + return testPassed! ? 'passed' : 'failed'; + } + + /// Whether a test is currently running + bool get isRunning => executionStatus == SpeedTestStatus.running; +} diff --git a/lib/features/speed_test/presentation/state/speed_test_run_state.freezed.dart b/lib/features/speed_test/presentation/state/speed_test_run_state.freezed.dart new file mode 100644 index 0000000..6a9ea3e --- /dev/null +++ b/lib/features/speed_test/presentation/state/speed_test_run_state.freezed.dart @@ -0,0 +1,866 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'speed_test_run_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$SpeedTestRunState { +// Execution status (idle, running, completed, error) + SpeedTestStatus get executionStatus => + throw _privateConstructorUsedError; // Progress (0-100) + double get progress => + throw _privateConstructorUsedError; // Status message (for UI display) + String? get statusMessage => + throw _privateConstructorUsedError; // Result data (from result stream) + double get downloadSpeed => throw _privateConstructorUsedError; + double get uploadSpeed => throw _privateConstructorUsedError; + double get latency => + throw _privateConstructorUsedError; // Validation status: null = not run, true = passed, false = failed + bool? get testPassed => throw _privateConstructorUsedError; // Error state + String? get errorMessage => + throw _privateConstructorUsedError; // Network info + String? get localIpAddress => throw _privateConstructorUsedError; + String? get gatewayAddress => + throw _privateConstructorUsedError; // Server configuration + String get serverHost => throw _privateConstructorUsedError; + int get serverPort => + throw _privateConstructorUsedError; // Test configuration + int get testDuration => throw _privateConstructorUsedError; + int get bandwidthMbps => throw _privateConstructorUsedError; + int get parallelStreams => throw _privateConstructorUsedError; + bool get useUdp => + throw _privateConstructorUsedError; // Full result object (for submission) + SpeedTestResult? get completedResult => throw _privateConstructorUsedError; + SpeedTestConfig? get config => + throw _privateConstructorUsedError; // Initialization flag + bool get isInitialized => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when( + TResult Function( + SpeedTestStatus executionStatus, + double progress, + String? statusMessage, + double downloadSpeed, + double uploadSpeed, + double latency, + bool? testPassed, + String? errorMessage, + String? localIpAddress, + String? gatewayAddress, + String serverHost, + int serverPort, + int testDuration, + int bandwidthMbps, + int parallelStreams, + bool useUdp, + SpeedTestResult? completedResult, + SpeedTestConfig? config, + bool isInitialized) + $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function( + SpeedTestStatus executionStatus, + double progress, + String? statusMessage, + double downloadSpeed, + double uploadSpeed, + double latency, + bool? testPassed, + String? errorMessage, + String? localIpAddress, + String? gatewayAddress, + String serverHost, + int serverPort, + int testDuration, + int bandwidthMbps, + int parallelStreams, + bool useUdp, + SpeedTestResult? completedResult, + SpeedTestConfig? config, + bool isInitialized)? + $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + SpeedTestStatus executionStatus, + double progress, + String? statusMessage, + double downloadSpeed, + double uploadSpeed, + double latency, + bool? testPassed, + String? errorMessage, + String? localIpAddress, + String? gatewayAddress, + String serverHost, + int serverPort, + int testDuration, + int bandwidthMbps, + int parallelStreams, + bool useUdp, + SpeedTestResult? completedResult, + SpeedTestConfig? config, + bool isInitialized)? + $default, { + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map( + TResult Function(_SpeedTestRunState value) $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_SpeedTestRunState value)? $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap( + TResult Function(_SpeedTestRunState value)? $default, { + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $SpeedTestRunStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpeedTestRunStateCopyWith<$Res> { + factory $SpeedTestRunStateCopyWith( + SpeedTestRunState value, $Res Function(SpeedTestRunState) then) = + _$SpeedTestRunStateCopyWithImpl<$Res, SpeedTestRunState>; + @useResult + $Res call( + {SpeedTestStatus executionStatus, + double progress, + String? statusMessage, + double downloadSpeed, + double uploadSpeed, + double latency, + bool? testPassed, + String? errorMessage, + String? localIpAddress, + String? gatewayAddress, + String serverHost, + int serverPort, + int testDuration, + int bandwidthMbps, + int parallelStreams, + bool useUdp, + SpeedTestResult? completedResult, + SpeedTestConfig? config, + bool isInitialized}); + + $SpeedTestResultCopyWith<$Res>? get completedResult; + $SpeedTestConfigCopyWith<$Res>? get config; +} + +/// @nodoc +class _$SpeedTestRunStateCopyWithImpl<$Res, $Val extends SpeedTestRunState> + implements $SpeedTestRunStateCopyWith<$Res> { + _$SpeedTestRunStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? executionStatus = null, + Object? progress = null, + Object? statusMessage = freezed, + Object? downloadSpeed = null, + Object? uploadSpeed = null, + Object? latency = null, + Object? testPassed = freezed, + Object? errorMessage = freezed, + Object? localIpAddress = freezed, + Object? gatewayAddress = freezed, + Object? serverHost = null, + Object? serverPort = null, + Object? testDuration = null, + Object? bandwidthMbps = null, + Object? parallelStreams = null, + Object? useUdp = null, + Object? completedResult = freezed, + Object? config = freezed, + Object? isInitialized = null, + }) { + return _then(_value.copyWith( + executionStatus: null == executionStatus + ? _value.executionStatus + : executionStatus // ignore: cast_nullable_to_non_nullable + as SpeedTestStatus, + progress: null == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as double, + statusMessage: freezed == statusMessage + ? _value.statusMessage + : statusMessage // ignore: cast_nullable_to_non_nullable + as String?, + downloadSpeed: null == downloadSpeed + ? _value.downloadSpeed + : downloadSpeed // ignore: cast_nullable_to_non_nullable + as double, + uploadSpeed: null == uploadSpeed + ? _value.uploadSpeed + : uploadSpeed // ignore: cast_nullable_to_non_nullable + as double, + latency: null == latency + ? _value.latency + : latency // ignore: cast_nullable_to_non_nullable + as double, + testPassed: freezed == testPassed + ? _value.testPassed + : testPassed // ignore: cast_nullable_to_non_nullable + as bool?, + errorMessage: freezed == errorMessage + ? _value.errorMessage + : errorMessage // ignore: cast_nullable_to_non_nullable + as String?, + localIpAddress: freezed == localIpAddress + ? _value.localIpAddress + : localIpAddress // ignore: cast_nullable_to_non_nullable + as String?, + gatewayAddress: freezed == gatewayAddress + ? _value.gatewayAddress + : gatewayAddress // ignore: cast_nullable_to_non_nullable + as String?, + serverHost: null == serverHost + ? _value.serverHost + : serverHost // ignore: cast_nullable_to_non_nullable + as String, + serverPort: null == serverPort + ? _value.serverPort + : serverPort // ignore: cast_nullable_to_non_nullable + as int, + testDuration: null == testDuration + ? _value.testDuration + : testDuration // ignore: cast_nullable_to_non_nullable + as int, + bandwidthMbps: null == bandwidthMbps + ? _value.bandwidthMbps + : bandwidthMbps // ignore: cast_nullable_to_non_nullable + as int, + parallelStreams: null == parallelStreams + ? _value.parallelStreams + : parallelStreams // ignore: cast_nullable_to_non_nullable + as int, + useUdp: null == useUdp + ? _value.useUdp + : useUdp // ignore: cast_nullable_to_non_nullable + as bool, + completedResult: freezed == completedResult + ? _value.completedResult + : completedResult // ignore: cast_nullable_to_non_nullable + as SpeedTestResult?, + config: freezed == config + ? _value.config + : config // ignore: cast_nullable_to_non_nullable + as SpeedTestConfig?, + isInitialized: null == isInitialized + ? _value.isInitialized + : isInitialized // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $SpeedTestResultCopyWith<$Res>? get completedResult { + if (_value.completedResult == null) { + return null; + } + + return $SpeedTestResultCopyWith<$Res>(_value.completedResult!, (value) { + return _then(_value.copyWith(completedResult: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $SpeedTestConfigCopyWith<$Res>? get config { + if (_value.config == null) { + return null; + } + + return $SpeedTestConfigCopyWith<$Res>(_value.config!, (value) { + return _then(_value.copyWith(config: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$SpeedTestRunStateImplCopyWith<$Res> + implements $SpeedTestRunStateCopyWith<$Res> { + factory _$$SpeedTestRunStateImplCopyWith(_$SpeedTestRunStateImpl value, + $Res Function(_$SpeedTestRunStateImpl) then) = + __$$SpeedTestRunStateImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {SpeedTestStatus executionStatus, + double progress, + String? statusMessage, + double downloadSpeed, + double uploadSpeed, + double latency, + bool? testPassed, + String? errorMessage, + String? localIpAddress, + String? gatewayAddress, + String serverHost, + int serverPort, + int testDuration, + int bandwidthMbps, + int parallelStreams, + bool useUdp, + SpeedTestResult? completedResult, + SpeedTestConfig? config, + bool isInitialized}); + + @override + $SpeedTestResultCopyWith<$Res>? get completedResult; + @override + $SpeedTestConfigCopyWith<$Res>? get config; +} + +/// @nodoc +class __$$SpeedTestRunStateImplCopyWithImpl<$Res> + extends _$SpeedTestRunStateCopyWithImpl<$Res, _$SpeedTestRunStateImpl> + implements _$$SpeedTestRunStateImplCopyWith<$Res> { + __$$SpeedTestRunStateImplCopyWithImpl(_$SpeedTestRunStateImpl _value, + $Res Function(_$SpeedTestRunStateImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? executionStatus = null, + Object? progress = null, + Object? statusMessage = freezed, + Object? downloadSpeed = null, + Object? uploadSpeed = null, + Object? latency = null, + Object? testPassed = freezed, + Object? errorMessage = freezed, + Object? localIpAddress = freezed, + Object? gatewayAddress = freezed, + Object? serverHost = null, + Object? serverPort = null, + Object? testDuration = null, + Object? bandwidthMbps = null, + Object? parallelStreams = null, + Object? useUdp = null, + Object? completedResult = freezed, + Object? config = freezed, + Object? isInitialized = null, + }) { + return _then(_$SpeedTestRunStateImpl( + executionStatus: null == executionStatus + ? _value.executionStatus + : executionStatus // ignore: cast_nullable_to_non_nullable + as SpeedTestStatus, + progress: null == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as double, + statusMessage: freezed == statusMessage + ? _value.statusMessage + : statusMessage // ignore: cast_nullable_to_non_nullable + as String?, + downloadSpeed: null == downloadSpeed + ? _value.downloadSpeed + : downloadSpeed // ignore: cast_nullable_to_non_nullable + as double, + uploadSpeed: null == uploadSpeed + ? _value.uploadSpeed + : uploadSpeed // ignore: cast_nullable_to_non_nullable + as double, + latency: null == latency + ? _value.latency + : latency // ignore: cast_nullable_to_non_nullable + as double, + testPassed: freezed == testPassed + ? _value.testPassed + : testPassed // ignore: cast_nullable_to_non_nullable + as bool?, + errorMessage: freezed == errorMessage + ? _value.errorMessage + : errorMessage // ignore: cast_nullable_to_non_nullable + as String?, + localIpAddress: freezed == localIpAddress + ? _value.localIpAddress + : localIpAddress // ignore: cast_nullable_to_non_nullable + as String?, + gatewayAddress: freezed == gatewayAddress + ? _value.gatewayAddress + : gatewayAddress // ignore: cast_nullable_to_non_nullable + as String?, + serverHost: null == serverHost + ? _value.serverHost + : serverHost // ignore: cast_nullable_to_non_nullable + as String, + serverPort: null == serverPort + ? _value.serverPort + : serverPort // ignore: cast_nullable_to_non_nullable + as int, + testDuration: null == testDuration + ? _value.testDuration + : testDuration // ignore: cast_nullable_to_non_nullable + as int, + bandwidthMbps: null == bandwidthMbps + ? _value.bandwidthMbps + : bandwidthMbps // ignore: cast_nullable_to_non_nullable + as int, + parallelStreams: null == parallelStreams + ? _value.parallelStreams + : parallelStreams // ignore: cast_nullable_to_non_nullable + as int, + useUdp: null == useUdp + ? _value.useUdp + : useUdp // ignore: cast_nullable_to_non_nullable + as bool, + completedResult: freezed == completedResult + ? _value.completedResult + : completedResult // ignore: cast_nullable_to_non_nullable + as SpeedTestResult?, + config: freezed == config + ? _value.config + : config // ignore: cast_nullable_to_non_nullable + as SpeedTestConfig?, + isInitialized: null == isInitialized + ? _value.isInitialized + : isInitialized // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _$SpeedTestRunStateImpl extends _SpeedTestRunState { + const _$SpeedTestRunStateImpl( + {this.executionStatus = SpeedTestStatus.idle, + this.progress = 0.0, + this.statusMessage, + this.downloadSpeed = 0.0, + this.uploadSpeed = 0.0, + this.latency = 0.0, + this.testPassed, + this.errorMessage, + this.localIpAddress, + this.gatewayAddress, + this.serverHost = '', + this.serverPort = 5201, + this.testDuration = 10, + this.bandwidthMbps = 0, + this.parallelStreams = 1, + this.useUdp = false, + this.completedResult, + this.config, + this.isInitialized = false}) + : super._(); + +// Execution status (idle, running, completed, error) + @override + @JsonKey() + final SpeedTestStatus executionStatus; +// Progress (0-100) + @override + @JsonKey() + final double progress; +// Status message (for UI display) + @override + final String? statusMessage; +// Result data (from result stream) + @override + @JsonKey() + final double downloadSpeed; + @override + @JsonKey() + final double uploadSpeed; + @override + @JsonKey() + final double latency; +// Validation status: null = not run, true = passed, false = failed + @override + final bool? testPassed; +// Error state + @override + final String? errorMessage; +// Network info + @override + final String? localIpAddress; + @override + final String? gatewayAddress; +// Server configuration + @override + @JsonKey() + final String serverHost; + @override + @JsonKey() + final int serverPort; +// Test configuration + @override + @JsonKey() + final int testDuration; + @override + @JsonKey() + final int bandwidthMbps; + @override + @JsonKey() + final int parallelStreams; + @override + @JsonKey() + final bool useUdp; +// Full result object (for submission) + @override + final SpeedTestResult? completedResult; + @override + final SpeedTestConfig? config; +// Initialization flag + @override + @JsonKey() + final bool isInitialized; + + @override + String toString() { + return 'SpeedTestRunState(executionStatus: $executionStatus, progress: $progress, statusMessage: $statusMessage, downloadSpeed: $downloadSpeed, uploadSpeed: $uploadSpeed, latency: $latency, testPassed: $testPassed, errorMessage: $errorMessage, localIpAddress: $localIpAddress, gatewayAddress: $gatewayAddress, serverHost: $serverHost, serverPort: $serverPort, testDuration: $testDuration, bandwidthMbps: $bandwidthMbps, parallelStreams: $parallelStreams, useUdp: $useUdp, completedResult: $completedResult, config: $config, isInitialized: $isInitialized)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpeedTestRunStateImpl && + (identical(other.executionStatus, executionStatus) || + other.executionStatus == executionStatus) && + (identical(other.progress, progress) || + other.progress == progress) && + (identical(other.statusMessage, statusMessage) || + other.statusMessage == statusMessage) && + (identical(other.downloadSpeed, downloadSpeed) || + other.downloadSpeed == downloadSpeed) && + (identical(other.uploadSpeed, uploadSpeed) || + other.uploadSpeed == uploadSpeed) && + (identical(other.latency, latency) || other.latency == latency) && + (identical(other.testPassed, testPassed) || + other.testPassed == testPassed) && + (identical(other.errorMessage, errorMessage) || + other.errorMessage == errorMessage) && + (identical(other.localIpAddress, localIpAddress) || + other.localIpAddress == localIpAddress) && + (identical(other.gatewayAddress, gatewayAddress) || + other.gatewayAddress == gatewayAddress) && + (identical(other.serverHost, serverHost) || + other.serverHost == serverHost) && + (identical(other.serverPort, serverPort) || + other.serverPort == serverPort) && + (identical(other.testDuration, testDuration) || + other.testDuration == testDuration) && + (identical(other.bandwidthMbps, bandwidthMbps) || + other.bandwidthMbps == bandwidthMbps) && + (identical(other.parallelStreams, parallelStreams) || + other.parallelStreams == parallelStreams) && + (identical(other.useUdp, useUdp) || other.useUdp == useUdp) && + (identical(other.completedResult, completedResult) || + other.completedResult == completedResult) && + (identical(other.config, config) || other.config == config) && + (identical(other.isInitialized, isInitialized) || + other.isInitialized == isInitialized)); + } + + @override + int get hashCode => Object.hashAll([ + runtimeType, + executionStatus, + progress, + statusMessage, + downloadSpeed, + uploadSpeed, + latency, + testPassed, + errorMessage, + localIpAddress, + gatewayAddress, + serverHost, + serverPort, + testDuration, + bandwidthMbps, + parallelStreams, + useUdp, + completedResult, + config, + isInitialized + ]); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpeedTestRunStateImplCopyWith<_$SpeedTestRunStateImpl> get copyWith => + __$$SpeedTestRunStateImplCopyWithImpl<_$SpeedTestRunStateImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when( + TResult Function( + SpeedTestStatus executionStatus, + double progress, + String? statusMessage, + double downloadSpeed, + double uploadSpeed, + double latency, + bool? testPassed, + String? errorMessage, + String? localIpAddress, + String? gatewayAddress, + String serverHost, + int serverPort, + int testDuration, + int bandwidthMbps, + int parallelStreams, + bool useUdp, + SpeedTestResult? completedResult, + SpeedTestConfig? config, + bool isInitialized) + $default, + ) { + return $default( + executionStatus, + progress, + statusMessage, + downloadSpeed, + uploadSpeed, + latency, + testPassed, + errorMessage, + localIpAddress, + gatewayAddress, + serverHost, + serverPort, + testDuration, + bandwidthMbps, + parallelStreams, + useUdp, + completedResult, + config, + isInitialized); + } + + @override + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function( + SpeedTestStatus executionStatus, + double progress, + String? statusMessage, + double downloadSpeed, + double uploadSpeed, + double latency, + bool? testPassed, + String? errorMessage, + String? localIpAddress, + String? gatewayAddress, + String serverHost, + int serverPort, + int testDuration, + int bandwidthMbps, + int parallelStreams, + bool useUdp, + SpeedTestResult? completedResult, + SpeedTestConfig? config, + bool isInitialized)? + $default, + ) { + return $default?.call( + executionStatus, + progress, + statusMessage, + downloadSpeed, + uploadSpeed, + latency, + testPassed, + errorMessage, + localIpAddress, + gatewayAddress, + serverHost, + serverPort, + testDuration, + bandwidthMbps, + parallelStreams, + useUdp, + completedResult, + config, + isInitialized); + } + + @override + @optionalTypeArgs + TResult maybeWhen( + TResult Function( + SpeedTestStatus executionStatus, + double progress, + String? statusMessage, + double downloadSpeed, + double uploadSpeed, + double latency, + bool? testPassed, + String? errorMessage, + String? localIpAddress, + String? gatewayAddress, + String serverHost, + int serverPort, + int testDuration, + int bandwidthMbps, + int parallelStreams, + bool useUdp, + SpeedTestResult? completedResult, + SpeedTestConfig? config, + bool isInitialized)? + $default, { + required TResult orElse(), + }) { + if ($default != null) { + return $default( + executionStatus, + progress, + statusMessage, + downloadSpeed, + uploadSpeed, + latency, + testPassed, + errorMessage, + localIpAddress, + gatewayAddress, + serverHost, + serverPort, + testDuration, + bandwidthMbps, + parallelStreams, + useUdp, + completedResult, + config, + isInitialized); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map( + TResult Function(_SpeedTestRunState value) $default, + ) { + return $default(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_SpeedTestRunState value)? $default, + ) { + return $default?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap( + TResult Function(_SpeedTestRunState value)? $default, { + required TResult orElse(), + }) { + if ($default != null) { + return $default(this); + } + return orElse(); + } +} + +abstract class _SpeedTestRunState extends SpeedTestRunState { + const factory _SpeedTestRunState( + {final SpeedTestStatus executionStatus, + final double progress, + final String? statusMessage, + final double downloadSpeed, + final double uploadSpeed, + final double latency, + final bool? testPassed, + final String? errorMessage, + final String? localIpAddress, + final String? gatewayAddress, + final String serverHost, + final int serverPort, + final int testDuration, + final int bandwidthMbps, + final int parallelStreams, + final bool useUdp, + final SpeedTestResult? completedResult, + final SpeedTestConfig? config, + final bool isInitialized}) = _$SpeedTestRunStateImpl; + const _SpeedTestRunState._() : super._(); + + @override // Execution status (idle, running, completed, error) + SpeedTestStatus get executionStatus; + @override // Progress (0-100) + double get progress; + @override // Status message (for UI display) + String? get statusMessage; + @override // Result data (from result stream) + double get downloadSpeed; + @override + double get uploadSpeed; + @override + double get latency; + @override // Validation status: null = not run, true = passed, false = failed + bool? get testPassed; + @override // Error state + String? get errorMessage; + @override // Network info + String? get localIpAddress; + @override + String? get gatewayAddress; + @override // Server configuration + String get serverHost; + @override + int get serverPort; + @override // Test configuration + int get testDuration; + @override + int get bandwidthMbps; + @override + int get parallelStreams; + @override + bool get useUdp; + @override // Full result object (for submission) + SpeedTestResult? get completedResult; + @override + SpeedTestConfig? get config; + @override // Initialization flag + bool get isInitialized; + @override + @JsonKey(ignore: true) + _$$SpeedTestRunStateImplCopyWith<_$SpeedTestRunStateImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/features/speed_test/presentation/widgets/speed_test_card.dart b/lib/features/speed_test/presentation/widgets/speed_test_card.dart index df6c4e0..f120227 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_card.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_card.dart @@ -1,12 +1,11 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; -import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; +import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; class SpeedTestCard extends ConsumerStatefulWidget { @@ -17,53 +16,13 @@ class SpeedTestCard extends ConsumerStatefulWidget { } class _SpeedTestCardState extends ConsumerState { - final SpeedTestService _speedTestService = SpeedTestService(); - SpeedTestStatus _status = SpeedTestStatus.idle; - SpeedTestResult? _lastResult; - double _progress = 0.0; - StreamSubscription? _statusSubscription; - StreamSubscription? _resultSubscription; - StreamSubscription? _progressSubscription; - @override void initState() { super.initState(); - _initializeService(); - } - - Future _initializeService() async { - await _speedTestService.initialize(); - - _status = _speedTestService.status; - _lastResult = _speedTestService.lastResult; - - _statusSubscription = _speedTestService.statusStream.listen((status) { - if (mounted) { - setState(() => _status = status); - } - }); - - _resultSubscription = _speedTestService.resultStream.listen((result) { - if (mounted) { - setState(() => _lastResult = result); - } - }); - - _progressSubscription = _speedTestService.progressStream.listen((progress) { - if (mounted) { - setState(() => _progress = progress); - } + // Initialize the notifier (idempotent - safe to call multiple times) + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(speedTestRunNotifierProvider.notifier).initialize(); }); - - if (mounted) setState(() {}); - } - - @override - void dispose() { - _statusSubscription?.cancel(); - _resultSubscription?.cancel(); - _progressSubscription?.cancel(); - super.dispose(); } String _formatSpeed(double speed) { @@ -74,11 +33,12 @@ class _SpeedTestCardState extends ConsumerState { } } - String _getLastTestTime() { - if (_lastResult == null) return 'Never'; + String _getLastTestTime(SpeedTestResult? lastResult) { + if (lastResult == null) return 'Never'; final now = DateTime.now(); - final diff = now.difference(_lastResult!.timestamp); + final timestamp = lastResult.completedAt ?? lastResult.timestamp; + final diff = now.difference(timestamp); if (diff.inMinutes < 1) { return 'Just now'; @@ -142,13 +102,9 @@ class _SpeedTestCardState extends ConsumerState { onCompleted: () { if (mounted) { LoggerService.info( - 'Speed test completed - reloading result for dashboard', + 'Speed test completed - UI will update via Riverpod', tag: 'SpeedTestCard', ); - final result = _speedTestService.lastResult; - setState(() { - _lastResult = result; - }); } }, onResultSubmitted: (result) async { @@ -209,56 +165,62 @@ class _SpeedTestCardState extends ConsumerState { void _showConfigDialog() { showDialog( context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Speed Test Settings'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SwitchListTile( - title: const Text('Use UDP Protocol'), - subtitle: Text(_speedTestService.useUdp - ? 'UDP (faster, less reliable)' - : 'TCP (slower, more reliable)'), - value: _speedTestService.useUdp, - onChanged: (value) { - _speedTestService.updateConfiguration(useUdp: value); - setState(() {}); - }, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.router), - title: const Text('Default Gateway'), - subtitle: Text( - '${_speedTestService.serverHost}:${_speedTestService.serverPort}'), - trailing: const Icon(Icons.info_outline), - ), - ListTile( - title: const Text('Test Duration'), - subtitle: Text('${_speedTestService.testDuration} seconds'), - trailing: const Icon(Icons.timer), - ), - ListTile( - title: const Text('Bandwidth Limit'), - subtitle: Text('${_speedTestService.bandwidthMbps} Mbps'), - trailing: const Icon(Icons.speed), + builder: (BuildContext dialogContext) { + return Consumer( + builder: (context, ref, child) { + final testState = ref.watch(speedTestRunNotifierProvider); + final notifier = ref.read(speedTestRunNotifierProvider.notifier); + + return AlertDialog( + title: const Text('Speed Test Settings'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SwitchListTile( + title: const Text('Use UDP Protocol'), + subtitle: Text(testState.useUdp + ? 'UDP (faster, less reliable)' + : 'TCP (slower, more reliable)'), + value: testState.useUdp, + onChanged: (value) { + notifier.updateConfiguration(useUdp: value); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.router), + title: const Text('Default Gateway'), + subtitle: Text( + '${testState.serverHost}:${testState.serverPort}'), + trailing: const Icon(Icons.info_outline), + ), + ListTile( + title: const Text('Test Duration'), + subtitle: Text('${testState.testDuration} seconds'), + trailing: const Icon(Icons.timer), + ), + ListTile( + title: const Text('Bandwidth Limit'), + subtitle: Text('${testState.bandwidthMbps} Mbps'), + trailing: const Icon(Icons.speed), + ), + ListTile( + title: const Text('Parallel Streams'), + subtitle: Text('${testState.parallelStreams} streams'), + trailing: const Icon(Icons.stream), + ), + ], ), - ListTile( - title: const Text('Parallel Streams'), - subtitle: Text('${_speedTestService.parallelStreams} streams'), - trailing: const Icon(Icons.stream), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close'), ), ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), - ), - ], + ); + }, ); }, ); @@ -266,6 +228,11 @@ class _SpeedTestCardState extends ConsumerState { @override Widget build(BuildContext context) { + final testState = ref.watch(speedTestRunNotifierProvider); + final status = testState.executionStatus; + final lastResult = testState.completedResult; + final hasError = lastResult?.hasError == true; + return GestureDetector( onLongPress: _showConfigDialog, child: Card( @@ -280,10 +247,10 @@ class _SpeedTestCardState extends ConsumerState { Row( children: [ Icon( - _status == SpeedTestStatus.running + status == SpeedTestStatus.running ? Icons.speed : Icons.network_check, - color: _status == SpeedTestStatus.running + color: status == SpeedTestStatus.running ? AppColors.primary : AppColors.gray500, size: 20, @@ -299,25 +266,25 @@ class _SpeedTestCardState extends ConsumerState { fontSize: 14, fontWeight: FontWeight.w600), ), Text( - _speedTestService.useUdp + testState.useUdp ? 'UDP Protocol' : 'TCP Protocol', style: TextStyle( fontSize: 10, color: AppColors.gray500), ), - if (_lastResult?.localIpAddress != null || - _lastResult?.serverHost != null) + if (testState.localIpAddress != null || + testState.serverHost.isNotEmpty) Text( - '${_lastResult?.localIpAddress ?? "Unknown"} → ${_lastResult?.serverHost ?? _speedTestService.serverHost}', + '${testState.localIpAddress ?? "Unknown"} → ${testState.serverHost}', style: TextStyle( fontSize: 9, color: AppColors.gray500), ), ], ), ), - if (_lastResult != null && !_lastResult!.hasError) + if (lastResult != null && !hasError) Text( - _getLastTestTime(), + _getLastTestTime(lastResult), style: TextStyle(fontSize: 10, color: AppColors.gray500), ), ], @@ -325,20 +292,20 @@ class _SpeedTestCardState extends ConsumerState { const SizedBox(height: 12), // Results or placeholder - if (_lastResult != null && !_lastResult!.hasError) ...[ + if (lastResult != null && !hasError) ...[ Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildSpeedMetric('Down', _lastResult!.downloadSpeed, + _buildSpeedMetric('Down', testState.downloadSpeed, AppColors.success, Icons.download), - _buildSpeedMetric('Up', _lastResult!.uploadSpeed, + _buildSpeedMetric('Up', testState.uploadSpeed, AppColors.info, Icons.upload), _buildSpeedMetric( - 'Ping', _lastResult!.latency, Colors.orange, Icons.timer, + 'Ping', testState.latency, Colors.orange, Icons.timer, isLatency: true), ], ), - ] else if (_lastResult?.hasError == true) ...[ + ] else if (hasError) ...[ Center( child: Column( children: [ @@ -348,9 +315,9 @@ class _SpeedTestCardState extends ConsumerState { const Text('Test failed', style: TextStyle(color: AppColors.error, fontSize: 12)), - if (_lastResult!.errorMessage != null) + if (testState.errorMessage != null) Text( - _lastResult!.errorMessage!, + testState.errorMessage!, style: const TextStyle( color: AppColors.error, fontSize: 10), textAlign: TextAlign.center, @@ -373,10 +340,10 @@ class _SpeedTestCardState extends ConsumerState { ], // Progress bar - if (_status == SpeedTestStatus.running) ...[ + if (status == SpeedTestStatus.running) ...[ const SizedBox(height: 12), LinearProgressIndicator( - value: _progress / 100, + value: testState.progress / 100, backgroundColor: AppColors.gray700, valueColor: const AlwaysStoppedAnimation(AppColors.primary), @@ -386,12 +353,12 @@ class _SpeedTestCardState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${_progress.toStringAsFixed(0)}% Complete', + '${testState.progress.toStringAsFixed(0)}% Complete', style: TextStyle(fontSize: 10, color: AppColors.gray500), ), - if (_speedTestService.serverHost.isNotEmpty) + if (testState.serverHost.isNotEmpty) Text( - 'Testing to ${_speedTestService.serverHost}', + 'Testing to ${testState.serverHost}', style: TextStyle( fontSize: 9, color: AppColors.gray500, @@ -406,27 +373,23 @@ class _SpeedTestCardState extends ConsumerState { // Action button Center( child: ElevatedButton.icon( - onPressed: _status == SpeedTestStatus.running + onPressed: status == SpeedTestStatus.running ? null : _showSpeedTestPopup, icon: Icon( - _status == SpeedTestStatus.running + status == SpeedTestStatus.running ? Icons.speed - : (_lastResult?.hasError == true - ? Icons.refresh - : Icons.play_arrow), + : (hasError ? Icons.refresh : Icons.play_arrow), size: 14, ), label: Text( - _status == SpeedTestStatus.running + status == SpeedTestStatus.running ? 'Test Running...' - : (_lastResult?.hasError == true - ? 'Retry Test' - : 'Run Test'), + : (hasError ? 'Retry Test' : 'Run Test'), style: const TextStyle(fontSize: 11), ), style: ElevatedButton.styleFrom( - backgroundColor: _status == SpeedTestStatus.running + backgroundColor: status == SpeedTestStatus.running ? AppColors.gray600 : AppColors.primary, foregroundColor: Colors.white, diff --git a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart index 7efa500..aec338f 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart @@ -1,15 +1,14 @@ -import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; -import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; -import 'package:rgnets_fdk/features/speed_test/data/services/network_gateway_service.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; +import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; -class SpeedTestPopup extends StatefulWidget { +class SpeedTestPopup extends ConsumerStatefulWidget { /// The speed test configuration (use this OR [speedTestWithResults]) final SpeedTestConfig? cachedTest; @@ -28,45 +27,26 @@ class SpeedTestPopup extends StatefulWidget { this.speedTestWithResults, this.onCompleted, this.onResultSubmitted, - }) : assert( - cachedTest != null || speedTestWithResults != null || true, - 'Either cachedTest or speedTestWithResults can be provided, or neither for a standalone test', - ); + }); @override - State createState() => _SpeedTestPopupState(); + ConsumerState createState() => _SpeedTestPopupState(); } -class _SpeedTestPopupState extends State +class _SpeedTestPopupState extends ConsumerState with SingleTickerProviderStateMixin { - final SpeedTestService _speedTestService = SpeedTestService(); - - SpeedTestStatus _status = SpeedTestStatus.idle; - double _downloadSpeed = 0.0; - double _uploadSpeed = 0.0; - double _latency = 0.0; - double _progress = 0.0; - String _currentPhase = 'Ready to start'; - String? _localIp; - String? _gatewayIp; - String? _serverHost; - String _serverLabel = 'Gateway'; - String? _errorMessage; - bool _testPassed = false; - - StreamSubscription? _statusSubscription; - StreamSubscription? _resultSubscription; - StreamSubscription? _progressSubscription; - StreamSubscription? _statusMessageSubscription; - late AnimationController _pulseController; late Animation _pulseAnimation; + bool _resultSubmitted = false; @override void initState() { super.initState(); _initializePulseAnimation(); - _initializeService(); + // Initialize notifier (idempotent) + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(speedTestRunNotifierProvider.notifier).initialize(); + }); } void _initializePulseAnimation() { @@ -80,239 +60,76 @@ class _SpeedTestPopupState extends State ); } - Future _initializeService() async { - await _speedTestService.initialize(); - - _status = SpeedTestStatus.idle; - - final gatewayService = NetworkGatewayService(); - _localIp = await gatewayService.getWifiIP(); - - _gatewayIp = await gatewayService.getWifiGateway(); - _serverHost = _gatewayIp; - _serverLabel = 'Gateway'; - - if (_localIp == null) { - LoggerService.warning( - 'Could not get device IP - location permission may be required on iOS', - tag: 'SpeedTestPopup'); - } - - if (mounted) { - setState(() {}); - } - - _statusSubscription = _speedTestService.statusStream.listen((status) { - if (!mounted) return; - setState(() { - _status = status; - _updatePhase(); - }); - }); - - _resultSubscription = _speedTestService.resultStream.listen((result) { - if (!mounted) return; - setState(() { - final serviceStatus = _speedTestService.status; - - if (result.hasError) { - _errorMessage = result.errorMessage; - _currentPhase = 'Test failed'; - } else { - // Update speeds (either live or final) - if (result.downloadSpeed > 0) _downloadSpeed = result.downloadSpeed; - if (result.uploadSpeed > 0) _uploadSpeed = result.uploadSpeed; - if (result.latency > 0) _latency = result.latency; - - // Only update connection info if it's a final result - if (result.localIpAddress != null) _localIp = result.localIpAddress; - if (result.serverHost != null) _serverHost = result.serverHost; - - // If the service finished but our local status hasn't updated yet, sync it - if (serviceStatus == SpeedTestStatus.completed && - _status != SpeedTestStatus.completed) { - _status = SpeedTestStatus.completed; - } - - // Check if this is a complete result - if (result.localIpAddress != null || - _status == SpeedTestStatus.completed || - serviceStatus == SpeedTestStatus.completed) { - _validateTestResults(); - _currentPhase = - _testPassed ? 'Test completed - PASSED!' : 'Test completed'; - } - } - }); - }); - - _progressSubscription = _speedTestService.progressStream.listen((progress) { - if (!mounted) return; - setState(() { - _progress = progress; - _updatePhase(); - }); - }); - - _statusMessageSubscription = - _speedTestService.statusMessageStream.listen((message) { - if (!mounted) return; - setState(() { - _currentPhase = message; - - // Extract server info from fallback attempt messages - if (message.contains('Default gateway')) { - _serverLabel = 'Gateway'; - final match = RegExp(r'\(([^)]+)\)').firstMatch(message); - if (match != null) { - _serverHost = match.group(1); - } - } else if (message.contains('test configuration') || - message.contains('Test configuration')) { - _serverLabel = 'Target'; - final match = RegExp(r'\(([^)]+)\)').firstMatch(message); - if (match != null) { - _serverHost = match.group(1); - } - } else if (message.contains('external server') || - message.contains('External server')) { - _serverLabel = 'External'; - final match = RegExp(r'\(([^)]+)\)').firstMatch(message); - if (match != null) { - _serverHost = match.group(1); - } - } else if (message.contains('Testing download speed to') || - message.contains('Testing upload speed to')) { - final match = RegExp(r'to ([\w\.\-]+)').firstMatch(message); - if (match != null) { - _serverHost = match.group(1); - _serverLabel = (_serverHost == _gatewayIp) ? 'Gateway' : 'Target'; - } - } - }); - }); + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); } - void _updatePhase() { - if (_status == SpeedTestStatus.running && - _currentPhase == 'Ready to start') { - if (_progress < 50) { - _currentPhase = 'Testing download speed...'; - } else { - _currentPhase = 'Testing upload speed...'; - } - } else if (_status == SpeedTestStatus.completed && - _currentPhase != 'Test completed!') { - _currentPhase = 'Test completed!'; - } else if (_status == SpeedTestStatus.error && - _currentPhase != 'Test failed') { - _currentPhase = 'Test failed'; - } + /// Get the effective config from either cachedTest or speedTestWithResults + SpeedTestConfig? get _effectiveConfig { + return widget.cachedTest ?? widget.speedTestWithResults?.config; } - Future _startTest() async { - final gatewayService = NetworkGatewayService(); - final gatewayIp = await gatewayService.getWifiGateway(); - - setState(() { - _currentPhase = 'Starting test...'; - _serverLabel = 'Gateway'; - _serverHost = gatewayIp ?? 'Detecting...'; - }); + double? _getMinDownload() => _effectiveConfig?.minDownloadMbps; + double? _getMinUpload() => _effectiveConfig?.minUploadMbps; + String? _getConfigTarget() => _effectiveConfig?.target; + String? _getConfigName() => _effectiveConfig?.name; - // Get target from effective config (works with both cachedTest and speedTestWithResults) + Future _startTest() async { + final notifier = ref.read(speedTestRunNotifierProvider.notifier); final configTarget = _getConfigTarget(); - // Run test: tries local gateway first, then falls back to config target - await _speedTestService.runSpeedTestWithFallback(configTarget: configTarget); + await notifier.startTest( + config: _effectiveConfig, + configTarget: configTarget, + ); } - void _cancelTest() async { - await _speedTestService.cancelTest(); + Future _cancelTest() async { + await ref.read(speedTestRunNotifierProvider.notifier).cancelTest(); if (mounted) { Navigator.of(context).pop(); } } - /// Get the effective config from either cachedTest or speedTestWithResults - SpeedTestConfig? get _effectiveConfig { - return widget.cachedTest ?? widget.speedTestWithResults?.config; - } - - double? _getMinDownload() { - return _effectiveConfig?.minDownloadMbps; - } - - double? _getMinUpload() { - return _effectiveConfig?.minUploadMbps; - } - - String? _getConfigTarget() { - return _effectiveConfig?.target; - } - - String? _getConfigName() { - return _effectiveConfig?.name; - } - - void _validateTestResults() { - final config = _effectiveConfig; - - if (config == null) { - _testPassed = true; - } else { - final minDownload = _getMinDownload(); - final minUpload = _getMinUpload(); + void _handleTestCompleted() { + if (_resultSubmitted) return; - final downloadPassed = minDownload == null || _downloadSpeed >= minDownload; - final uploadPassed = minUpload == null || _uploadSpeed >= minUpload; + final testState = ref.read(speedTestRunNotifierProvider); + final result = testState.completedResult; - _testPassed = downloadPassed && uploadPassed; - } + if (result == null) return; - // Auto-submit result when test completes (passed or failed) + // Submit result via callback if provided if (widget.onResultSubmitted != null) { - _submitResult(); + final submitResult = SpeedTestResult( + downloadMbps: testState.downloadSpeed, + uploadMbps: testState.uploadSpeed, + rtt: testState.latency, + localIpAddress: testState.localIpAddress, + serverHost: testState.serverHost, + speedTestId: _effectiveConfig?.id, + passed: testState.testPassed ?? false, + completedAt: DateTime.now(), + testType: 'iperf3', + source: testState.localIpAddress, + destination: testState.serverHost, + port: testState.serverPort, + iperfProtocol: testState.useUdp ? 'udp' : 'tcp', + ); + + LoggerService.info( + 'SpeedTestPopup: Auto-submitting result - passed=${testState.testPassed}, ' + 'download=${testState.downloadSpeed}, upload=${testState.uploadSpeed}', + tag: 'SpeedTestPopup', + ); + + widget.onResultSubmitted?.call(submitResult); + _resultSubmitted = true; } } - /// Submit the test result via callback - void _submitResult() { - final result = SpeedTestResult( - downloadMbps: _downloadSpeed, - uploadMbps: _uploadSpeed, - rtt: _latency, - localIpAddress: _localIp, - serverHost: _serverHost, - speedTestId: _effectiveConfig?.id, - passed: _testPassed, - completedAt: DateTime.now(), - testType: 'iperf3', - source: _localIp, - destination: _serverHost, - port: _speedTestService.serverPort, - iperfProtocol: _speedTestService.useUdp ? 'udp' : 'tcp', - ); - - LoggerService.info( - 'SpeedTestPopup: Auto-submitting result - passed=$_testPassed, ' - 'download=$_downloadSpeed, upload=$_uploadSpeed', - tag: 'SpeedTestPopup', - ); - - widget.onResultSubmitted?.call(result); - } - - @override - void dispose() { - _statusSubscription?.cancel(); - _resultSubscription?.cancel(); - _progressSubscription?.cancel(); - _statusMessageSubscription?.cancel(); - _pulseController.dispose(); - super.dispose(); - } - String _formatSpeed(double speed) { if (speed < 1000.0) { return '${speed.toStringAsFixed(2)} Mbps'; @@ -321,8 +138,8 @@ class _SpeedTestPopupState extends State } } - Color _getStatusColor() { - switch (_status) { + Color _getStatusColor(SpeedTestStatus status) { + switch (status) { case SpeedTestStatus.running: return AppColors.primary; case SpeedTestStatus.completed: @@ -334,8 +151,8 @@ class _SpeedTestPopupState extends State } } - IconData _getStatusIcon() { - switch (_status) { + IconData _getStatusIcon(SpeedTestStatus status) { + switch (status) { case SpeedTestStatus.running: return Icons.speed; case SpeedTestStatus.completed: @@ -414,7 +231,7 @@ class _SpeedTestPopupState extends State ); } - Widget _buildLatencyIndicator() { + Widget _buildLatencyIndicator(double latency) { return Container( padding: const EdgeInsets.all(12), constraints: const BoxConstraints( @@ -434,7 +251,7 @@ class _SpeedTestPopupState extends State SizedBox( width: 110, child: Text( - '${_latency.toStringAsFixed(0)} ms', + '${latency.toStringAsFixed(0)} ms', textAlign: TextAlign.center, style: const TextStyle( fontSize: 14, @@ -459,8 +276,19 @@ class _SpeedTestPopupState extends State @override Widget build(BuildContext context) { + final testState = ref.watch(speedTestRunNotifierProvider); + final status = testState.executionStatus; + final testPassed = testState.testPassed; + + // Auto-submit when test completes + if (status == SpeedTestStatus.completed && !_resultSubmitted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleTestCompleted(); + }); + } + return PopScope( - canPop: _status != SpeedTestStatus.running, + canPop: status != SpeedTestStatus.running, child: Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Container( @@ -477,18 +305,18 @@ class _SpeedTestPopupState extends State animation: _pulseAnimation, builder: (context, child) { return Transform.scale( - scale: _status == SpeedTestStatus.running + scale: status == SpeedTestStatus.running ? _pulseAnimation.value : 1.0, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: _getStatusColor().withOpacity(0.2), + color: _getStatusColor(status).withOpacity(0.2), shape: BoxShape.circle, ), child: Icon( - _getStatusIcon(), - color: _getStatusColor(), + _getStatusIcon(status), + color: _getStatusColor(status), size: 32, ), ), @@ -508,7 +336,7 @@ class _SpeedTestPopupState extends State ), ), Text( - _currentPhase, + testState.statusMessage ?? 'Ready to start', style: TextStyle( fontSize: 14, color: AppColors.gray500, @@ -517,7 +345,7 @@ class _SpeedTestPopupState extends State ], ), ), - if (_status != SpeedTestStatus.running) + if (status != SpeedTestStatus.running) IconButton( icon: const Icon(Icons.close), onPressed: () { @@ -544,11 +372,11 @@ class _SpeedTestPopupState extends State Row( children: [ Icon( - _localIp != null + testState.localIpAddress != null ? Icons.computer : Icons.location_off, size: 16, - color: _localIp != null + color: testState.localIpAddress != null ? AppColors.gray500 : Colors.orange, ), @@ -560,9 +388,9 @@ class _SpeedTestPopupState extends State color: AppColors.gray500, ), ), - if (_localIp != null) + if (testState.localIpAddress != null) Text( - _localIp!, + testState.localIpAddress!, style: TextStyle( fontSize: 12, color: AppColors.gray300, @@ -595,18 +423,18 @@ class _SpeedTestPopupState extends State ), ), Text( - _serverLabel, + 'Target', style: TextStyle( fontSize: 12, color: AppColors.primary, fontWeight: FontWeight.bold, ), ), - if (_serverHost != null) ...[ + if (testState.serverHost.isNotEmpty) ...[ const SizedBox(width: 4), Flexible( child: Text( - '($_serverHost)', + '(${testState.serverHost})', style: TextStyle( fontSize: 11, color: AppColors.gray500, @@ -766,7 +594,7 @@ class _SpeedTestPopupState extends State Expanded( child: _buildSpeedIndicator( 'Download', - _downloadSpeed, + testState.downloadSpeed, Icons.download, AppColors.success, minRequired: _getMinDownload(), @@ -776,7 +604,7 @@ class _SpeedTestPopupState extends State Expanded( child: _buildSpeedIndicator( 'Upload', - _uploadSpeed, + testState.uploadSpeed, Icons.upload, AppColors.info, minRequired: _getMinUpload(), @@ -791,13 +619,13 @@ class _SpeedTestPopupState extends State // Latency indicator SizedBox( height: 110, - child: _buildLatencyIndicator(), + child: _buildLatencyIndicator(testState.latency), ), const SizedBox(height: 20), // Progress indicator - if (_status == SpeedTestStatus.running) ...[ + if (status == SpeedTestStatus.running) ...[ Center( child: Column( children: [ @@ -805,18 +633,18 @@ class _SpeedTestPopupState extends State width: 48, height: 48, child: CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation(_getStatusColor()), + valueColor: AlwaysStoppedAnimation( + _getStatusColor(status)), strokeWidth: 4, ), ), const SizedBox(height: 12), Text( - _currentPhase, + testState.statusMessage ?? 'Testing...', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: _getStatusColor(), + color: _getStatusColor(status), ), ), ], @@ -825,14 +653,15 @@ class _SpeedTestPopupState extends State ], // Error message - if (_errorMessage != null) ...[ + if (testState.errorMessage != null) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.error.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.error.withOpacity(0.3)), + border: + Border.all(color: AppColors.error.withOpacity(0.3)), ), child: Row( children: [ @@ -841,7 +670,7 @@ class _SpeedTestPopupState extends State const SizedBox(width: 8), Expanded( child: Text( - _errorMessage!, + testState.errorMessage!, style: const TextStyle( color: AppColors.error, fontSize: 12, @@ -854,15 +683,16 @@ class _SpeedTestPopupState extends State ], // Threshold failure alert - if (_status == SpeedTestStatus.completed && !_testPassed) ...[ + if (status == SpeedTestStatus.completed && + testPassed == false) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.warning.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: - Border.all(color: AppColors.warning.withOpacity(0.3)), + border: Border.all( + color: AppColors.warning.withOpacity(0.3)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -901,7 +731,7 @@ class _SpeedTestPopupState extends State // Action buttons const SizedBox(height: 16), - if (_status == SpeedTestStatus.idle) ...[ + if (status == SpeedTestStatus.idle) ...[ Row( children: [ Expanded( @@ -935,7 +765,7 @@ class _SpeedTestPopupState extends State ), ], - if (_status == SpeedTestStatus.running) ...[ + if (status == SpeedTestStatus.running) ...[ SizedBox( width: double.infinity, child: OutlinedButton.icon( @@ -950,7 +780,7 @@ class _SpeedTestPopupState extends State ), ], - if (_status == SpeedTestStatus.completed) ...[ + if (status == SpeedTestStatus.completed) ...[ Row( children: [ Expanded( @@ -973,14 +803,10 @@ class _SpeedTestPopupState extends State Expanded( child: ElevatedButton.icon( onPressed: () { - setState(() { - _downloadSpeed = 0.0; - _uploadSpeed = 0.0; - _latency = 0.0; - _progress = 0.0; - _errorMessage = null; - _testPassed = false; - }); + _resultSubmitted = false; + ref + .read(speedTestRunNotifierProvider.notifier) + .reset(); _startTest(); }, icon: const Icon(Icons.refresh, size: 16), @@ -997,7 +823,7 @@ class _SpeedTestPopupState extends State ), ], - if (_status == SpeedTestStatus.error) ...[ + if (status == SpeedTestStatus.error) ...[ Row( children: [ Expanded( @@ -1019,13 +845,10 @@ class _SpeedTestPopupState extends State Expanded( child: ElevatedButton.icon( onPressed: () { - setState(() { - _errorMessage = null; - _downloadSpeed = 0.0; - _uploadSpeed = 0.0; - _latency = 0.0; - _progress = 0.0; - }); + _resultSubmitted = false; + ref + .read(speedTestRunNotifierProvider.notifier) + .reset(); _startTest(); }, icon: const Icon(Icons.refresh, size: 16), From 11581252dca0233c94f36e647ade562f4f7e3f4c Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Tue, 20 Jan 2026 15:56:39 -0800 Subject: [PATCH 07/24] Removed join table unecessary --- .../speed_test_repository_impl.dart | 78 ----- .../data/services/speed_test_run_state.dart | 0 .../entities/speed_test_with_results.dart | 69 ----- .../speed_test_with_results.freezed.dart | 271 ------------------ .../repositories/speed_test_repository.dart | 12 - .../providers/speed_test_providers.dart | 91 ------ .../providers/speed_test_providers.g.dart | 167 ----------- .../widgets/speed_test_popup.dart | 14 +- 8 files changed, 3 insertions(+), 699 deletions(-) delete mode 100644 lib/features/speed_test/data/services/speed_test_run_state.dart delete mode 100644 lib/features/speed_test/domain/entities/speed_test_with_results.dart delete mode 100644 lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart diff --git a/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart b/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart index 60e58cf..73f4756 100644 --- a/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart +++ b/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart @@ -4,7 +4,6 @@ import 'package:rgnets_fdk/core/errors/failures.dart'; import 'package:rgnets_fdk/features/speed_test/data/datasources/speed_test_data_source.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; import 'package:rgnets_fdk/features/speed_test/domain/repositories/speed_test_repository.dart'; /// Implementation of [SpeedTestRepository] using WebSocket data source. @@ -124,83 +123,6 @@ class SpeedTestRepositoryImpl implements SpeedTestRepository { } } - // ============================================================================ - // Joined Operations - // ============================================================================ - - @override - Future> getSpeedTestWithResults( - int id, - ) async { - try { - _logger.i('SpeedTestRepositoryImpl: getSpeedTestWithResults($id) called'); - - // Fetch config and results in parallel - final configFuture = _dataSource.getSpeedTestConfig(id); - final resultsFuture = _dataSource.getSpeedTestResults(speedTestId: id); - - final config = await configFuture; - final results = await resultsFuture; - - final joined = SpeedTestWithResults( - config: config, - results: results, - ); - - _logger.i( - 'SpeedTestRepositoryImpl: Got config $id with ${results.length} results', - ); - return Right(joined); - } on Exception catch (e) { - _logger.e( - 'SpeedTestRepositoryImpl: Failed to get speed test with results: $e', - ); - return Left(_mapExceptionToFailure(e)); - } - } - - @override - Future>> - getAllSpeedTestsWithResults() async { - try { - _logger.i( - 'SpeedTestRepositoryImpl: getAllSpeedTestsWithResults() called', - ); - - // Fetch all configs and results - final configs = await _dataSource.getSpeedTestConfigs(); - final allResults = await _dataSource.getSpeedTestResults(); - - // Group results by speedTestId - final resultsByConfigId = >{}; - for (final result in allResults) { - if (result.speedTestId != null) { - resultsByConfigId - .putIfAbsent(result.speedTestId!, () => []) - .add(result); - } - } - - // Join configs with their results - final joined = configs.map((config) { - final results = config.id != null - ? (resultsByConfigId[config.id!] ?? []) - : []; - return SpeedTestWithResults(config: config, results: results); - }).toList(); - - _logger.i( - 'SpeedTestRepositoryImpl: Got ${joined.length} speed tests with results', - ); - return Right(joined); - } on Exception catch (e) { - _logger.e( - 'SpeedTestRepositoryImpl: Failed to get all speed tests with results: $e', - ); - return Left(_mapExceptionToFailure(e)); - } - } - // ============================================================================ // Helper Methods // ============================================================================ diff --git a/lib/features/speed_test/data/services/speed_test_run_state.dart b/lib/features/speed_test/data/services/speed_test_run_state.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/features/speed_test/domain/entities/speed_test_with_results.dart b/lib/features/speed_test/domain/entities/speed_test_with_results.dart deleted file mode 100644 index 8c0822d..0000000 --- a/lib/features/speed_test/domain/entities/speed_test_with_results.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; - -part 'speed_test_with_results.freezed.dart'; - -/// A joined entity containing a speed test configuration with its associated results. -/// Note: This is a view model created in code, not from JSON. -@Freezed(toJson: false, fromJson: false) -class SpeedTestWithResults with _$SpeedTestWithResults { - const factory SpeedTestWithResults({ - required SpeedTestConfig config, - @Default([]) List results, - }) = _SpeedTestWithResults; - - const SpeedTestWithResults._(); - - /// Get the most recent result - SpeedTestResult? get latestResult { - if (results.isEmpty) return null; - return results.reduce((a, b) { - final aTime = a.completedAt ?? a.createdAt ?? DateTime(1970); - final bTime = b.completedAt ?? b.createdAt ?? DateTime(1970); - return aTime.isAfter(bTime) ? a : b; - }); - } - - /// Get the number of results - int get resultCount => results.length; - - /// Check if there are any results - bool get hasResults => results.isNotEmpty; - - /// Get passing results only - List get passingResults => - results.where((r) => r.passed).toList(); - - /// Get failing results only - List get failingResults => - results.where((r) => !r.passed).toList(); - - /// Calculate pass rate as percentage - double get passRate { - if (results.isEmpty) return 0.0; - return (passingResults.length / results.length) * 100; - } - - /// Check if the test is currently passing (based on latest result) - bool get isCurrentlyPassing => latestResult?.passed ?? false; - - /// Check if meets minimum download requirement - bool get meetsDownloadRequirement { - final latest = latestResult; - if (latest?.downloadMbps == null || config.minDownloadMbps == null) { - return true; - } - return latest!.downloadMbps! >= config.minDownloadMbps!; - } - - /// Check if meets minimum upload requirement - bool get meetsUploadRequirement { - final latest = latestResult; - if (latest?.uploadMbps == null || config.minUploadMbps == null) { - return true; - } - return latest!.uploadMbps! >= config.minUploadMbps!; - } -} diff --git a/lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart b/lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart deleted file mode 100644 index 05095ea..0000000 --- a/lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart +++ /dev/null @@ -1,271 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'speed_test_with_results.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -/// @nodoc -mixin _$SpeedTestWithResults { - SpeedTestConfig get config => throw _privateConstructorUsedError; - List get results => throw _privateConstructorUsedError; - @optionalTypeArgs - TResult when( - TResult Function(SpeedTestConfig config, List results) - $default, - ) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? whenOrNull( - TResult? Function(SpeedTestConfig config, List results)? - $default, - ) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeWhen( - TResult Function(SpeedTestConfig config, List results)? - $default, { - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult map( - TResult Function(_SpeedTestWithResults value) $default, - ) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? mapOrNull( - TResult? Function(_SpeedTestWithResults value)? $default, - ) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeMap( - TResult Function(_SpeedTestWithResults value)? $default, { - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - - @JsonKey(ignore: true) - $SpeedTestWithResultsCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SpeedTestWithResultsCopyWith<$Res> { - factory $SpeedTestWithResultsCopyWith(SpeedTestWithResults value, - $Res Function(SpeedTestWithResults) then) = - _$SpeedTestWithResultsCopyWithImpl<$Res, SpeedTestWithResults>; - @useResult - $Res call({SpeedTestConfig config, List results}); - - $SpeedTestConfigCopyWith<$Res> get config; -} - -/// @nodoc -class _$SpeedTestWithResultsCopyWithImpl<$Res, - $Val extends SpeedTestWithResults> - implements $SpeedTestWithResultsCopyWith<$Res> { - _$SpeedTestWithResultsCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? config = null, - Object? results = null, - }) { - return _then(_value.copyWith( - config: null == config - ? _value.config - : config // ignore: cast_nullable_to_non_nullable - as SpeedTestConfig, - results: null == results - ? _value.results - : results // ignore: cast_nullable_to_non_nullable - as List, - ) as $Val); - } - - @override - @pragma('vm:prefer-inline') - $SpeedTestConfigCopyWith<$Res> get config { - return $SpeedTestConfigCopyWith<$Res>(_value.config, (value) { - return _then(_value.copyWith(config: value) as $Val); - }); - } -} - -/// @nodoc -abstract class _$$SpeedTestWithResultsImplCopyWith<$Res> - implements $SpeedTestWithResultsCopyWith<$Res> { - factory _$$SpeedTestWithResultsImplCopyWith(_$SpeedTestWithResultsImpl value, - $Res Function(_$SpeedTestWithResultsImpl) then) = - __$$SpeedTestWithResultsImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({SpeedTestConfig config, List results}); - - @override - $SpeedTestConfigCopyWith<$Res> get config; -} - -/// @nodoc -class __$$SpeedTestWithResultsImplCopyWithImpl<$Res> - extends _$SpeedTestWithResultsCopyWithImpl<$Res, _$SpeedTestWithResultsImpl> - implements _$$SpeedTestWithResultsImplCopyWith<$Res> { - __$$SpeedTestWithResultsImplCopyWithImpl(_$SpeedTestWithResultsImpl _value, - $Res Function(_$SpeedTestWithResultsImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? config = null, - Object? results = null, - }) { - return _then(_$SpeedTestWithResultsImpl( - config: null == config - ? _value.config - : config // ignore: cast_nullable_to_non_nullable - as SpeedTestConfig, - results: null == results - ? _value._results - : results // ignore: cast_nullable_to_non_nullable - as List, - )); - } -} - -/// @nodoc - -class _$SpeedTestWithResultsImpl extends _SpeedTestWithResults { - const _$SpeedTestWithResultsImpl( - {required this.config, final List results = const []}) - : _results = results, - super._(); - - @override - final SpeedTestConfig config; - final List _results; - @override - @JsonKey() - List get results { - if (_results is EqualUnmodifiableListView) return _results; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_results); - } - - @override - String toString() { - return 'SpeedTestWithResults(config: $config, results: $results)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SpeedTestWithResultsImpl && - (identical(other.config, config) || other.config == config) && - const DeepCollectionEquality().equals(other._results, _results)); - } - - @override - int get hashCode => Object.hash( - runtimeType, config, const DeepCollectionEquality().hash(_results)); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$SpeedTestWithResultsImplCopyWith<_$SpeedTestWithResultsImpl> - get copyWith => - __$$SpeedTestWithResultsImplCopyWithImpl<_$SpeedTestWithResultsImpl>( - this, _$identity); - - @override - @optionalTypeArgs - TResult when( - TResult Function(SpeedTestConfig config, List results) - $default, - ) { - return $default(config, results); - } - - @override - @optionalTypeArgs - TResult? whenOrNull( - TResult? Function(SpeedTestConfig config, List results)? - $default, - ) { - return $default?.call(config, results); - } - - @override - @optionalTypeArgs - TResult maybeWhen( - TResult Function(SpeedTestConfig config, List results)? - $default, { - required TResult orElse(), - }) { - if ($default != null) { - return $default(config, results); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map( - TResult Function(_SpeedTestWithResults value) $default, - ) { - return $default(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull( - TResult? Function(_SpeedTestWithResults value)? $default, - ) { - return $default?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap( - TResult Function(_SpeedTestWithResults value)? $default, { - required TResult orElse(), - }) { - if ($default != null) { - return $default(this); - } - return orElse(); - } -} - -abstract class _SpeedTestWithResults extends SpeedTestWithResults { - const factory _SpeedTestWithResults( - {required final SpeedTestConfig config, - final List results}) = _$SpeedTestWithResultsImpl; - const _SpeedTestWithResults._() : super._(); - - @override - SpeedTestConfig get config; - @override - List get results; - @override - @JsonKey(ignore: true) - _$$SpeedTestWithResultsImplCopyWith<_$SpeedTestWithResultsImpl> - get copyWith => throw _privateConstructorUsedError; -} diff --git a/lib/features/speed_test/domain/repositories/speed_test_repository.dart b/lib/features/speed_test/domain/repositories/speed_test_repository.dart index fd4fb7f..65bb24f 100644 --- a/lib/features/speed_test/domain/repositories/speed_test_repository.dart +++ b/lib/features/speed_test/domain/repositories/speed_test_repository.dart @@ -3,7 +3,6 @@ import 'package:fpdart/fpdart.dart'; import 'package:rgnets_fdk/core/errors/failures.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; /// Repository interface for speed test configurations and results. abstract class SpeedTestRepository { @@ -41,15 +40,4 @@ abstract class SpeedTestRepository { Future> updateSpeedTestResult( SpeedTestResult result, ); - - // ============================================================================ - // Joined Operations - // ============================================================================ - - /// Get a speed test configuration with all its results - Future> getSpeedTestWithResults(int id); - - /// Get all speed test configurations with their results - Future>> - getAllSpeedTestsWithResults(); } diff --git a/lib/features/speed_test/presentation/providers/speed_test_providers.dart b/lib/features/speed_test/presentation/providers/speed_test_providers.dart index fd8790b..2615e8b 100644 --- a/lib/features/speed_test/presentation/providers/speed_test_providers.dart +++ b/lib/features/speed_test/presentation/providers/speed_test_providers.dart @@ -13,7 +13,6 @@ import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service. import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; import 'package:rgnets_fdk/features/speed_test/domain/repositories/speed_test_repository.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/state/speed_test_run_state.dart'; @@ -175,96 +174,6 @@ class SpeedTestResultsNotifier extends _$SpeedTestResultsNotifier { } } -// ============================================================================ -// Speed Test With Results Provider (Joined) -// ============================================================================ - -@Riverpod(keepAlive: true) -class SpeedTestWithResultsNotifier extends _$SpeedTestWithResultsNotifier { - Logger get _logger => ref.read(loggerProvider); - - @override - Future build(int configId) async { - _logger.i('SpeedTestWithResultsNotifier: Loading config $configId'); - - final repository = ref.watch(speedTestRepositoryProvider); - final result = await repository.getSpeedTestWithResults(configId); - - return result.fold( - (failure) { - _logger.e( - 'SpeedTestWithResultsNotifier: Failed - ${failure.message}', - ); - throw Exception(failure.message); - }, - (joined) { - _logger.i( - 'SpeedTestWithResultsNotifier: Loaded config $configId ' - 'with ${joined.resultCount} results', - ); - return joined; - }, - ); - } - - Future refresh() async { - state = const AsyncValue.loading(); - state = await AsyncValue.guard(() async { - final repository = ref.read(speedTestRepositoryProvider); - final result = await repository.getSpeedTestWithResults(configId); - return result.fold( - (failure) => throw Exception(failure.message), - (joined) => joined, - ); - }); - } -} - -// ============================================================================ -// All Speed Tests With Results Provider -// ============================================================================ - -@Riverpod(keepAlive: true) -class AllSpeedTestsWithResultsNotifier - extends _$AllSpeedTestsWithResultsNotifier { - Logger get _logger => ref.read(loggerProvider); - - @override - Future> build() async { - _logger.i('AllSpeedTestsWithResultsNotifier: Loading all speed tests'); - - final repository = ref.watch(speedTestRepositoryProvider); - final result = await repository.getAllSpeedTestsWithResults(); - - return result.fold( - (failure) { - _logger.e( - 'AllSpeedTestsWithResultsNotifier: Failed - ${failure.message}', - ); - throw Exception(failure.message); - }, - (joined) { - _logger.i( - 'AllSpeedTestsWithResultsNotifier: Loaded ${joined.length} speed tests', - ); - return joined; - }, - ); - } - - Future refresh() async { - state = const AsyncValue.loading(); - state = await AsyncValue.guard(() async { - final repository = ref.read(speedTestRepositoryProvider); - final result = await repository.getAllSpeedTestsWithResults(); - return result.fold( - (failure) => throw Exception(failure.message), - (joined) => joined, - ); - }); - } -} - // ============================================================================ // Speed Test Run Notifier (for running tests via Riverpod) // ============================================================================ diff --git a/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart b/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart index 514ab6d..3b2f519 100644 --- a/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart +++ b/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart @@ -248,173 +248,6 @@ class _SpeedTestResultsNotifierProviderElement (origin as SpeedTestResultsNotifierProvider).accessPointId; } -String _$speedTestWithResultsNotifierHash() => - r'ca7c8b8e92c543d1ca0e47ee04a15a7a328c6d40'; - -abstract class _$SpeedTestWithResultsNotifier - extends BuildlessAsyncNotifier { - late final int configId; - - FutureOr build( - int configId, - ); -} - -/// See also [SpeedTestWithResultsNotifier]. -@ProviderFor(SpeedTestWithResultsNotifier) -const speedTestWithResultsNotifierProvider = - SpeedTestWithResultsNotifierFamily(); - -/// See also [SpeedTestWithResultsNotifier]. -class SpeedTestWithResultsNotifierFamily - extends Family> { - /// See also [SpeedTestWithResultsNotifier]. - const SpeedTestWithResultsNotifierFamily(); - - /// See also [SpeedTestWithResultsNotifier]. - SpeedTestWithResultsNotifierProvider call( - int configId, - ) { - return SpeedTestWithResultsNotifierProvider( - configId, - ); - } - - @override - SpeedTestWithResultsNotifierProvider getProviderOverride( - covariant SpeedTestWithResultsNotifierProvider provider, - ) { - return call( - provider.configId, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'speedTestWithResultsNotifierProvider'; -} - -/// See also [SpeedTestWithResultsNotifier]. -class SpeedTestWithResultsNotifierProvider extends AsyncNotifierProviderImpl< - SpeedTestWithResultsNotifier, SpeedTestWithResults> { - /// See also [SpeedTestWithResultsNotifier]. - SpeedTestWithResultsNotifierProvider( - int configId, - ) : this._internal( - () => SpeedTestWithResultsNotifier()..configId = configId, - from: speedTestWithResultsNotifierProvider, - name: r'speedTestWithResultsNotifierProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$speedTestWithResultsNotifierHash, - dependencies: SpeedTestWithResultsNotifierFamily._dependencies, - allTransitiveDependencies: - SpeedTestWithResultsNotifierFamily._allTransitiveDependencies, - configId: configId, - ); - - SpeedTestWithResultsNotifierProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.configId, - }) : super.internal(); - - final int configId; - - @override - FutureOr runNotifierBuild( - covariant SpeedTestWithResultsNotifier notifier, - ) { - return notifier.build( - configId, - ); - } - - @override - Override overrideWith(SpeedTestWithResultsNotifier Function() create) { - return ProviderOverride( - origin: this, - override: SpeedTestWithResultsNotifierProvider._internal( - () => create()..configId = configId, - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - configId: configId, - ), - ); - } - - @override - AsyncNotifierProviderElement createElement() { - return _SpeedTestWithResultsNotifierProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is SpeedTestWithResultsNotifierProvider && - other.configId == configId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, configId.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin SpeedTestWithResultsNotifierRef - on AsyncNotifierProviderRef { - /// The parameter `configId` of this provider. - int get configId; -} - -class _SpeedTestWithResultsNotifierProviderElement - extends AsyncNotifierProviderElement with SpeedTestWithResultsNotifierRef { - _SpeedTestWithResultsNotifierProviderElement(super.provider); - - @override - int get configId => (origin as SpeedTestWithResultsNotifierProvider).configId; -} - -String _$allSpeedTestsWithResultsNotifierHash() => - r'd773bec35269df06902eed57df641eb48a46c935'; - -/// See also [AllSpeedTestsWithResultsNotifier]. -@ProviderFor(AllSpeedTestsWithResultsNotifier) -final allSpeedTestsWithResultsNotifierProvider = AsyncNotifierProvider< - AllSpeedTestsWithResultsNotifier, List>.internal( - AllSpeedTestsWithResultsNotifier.new, - name: r'allSpeedTestsWithResultsNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$allSpeedTestsWithResultsNotifierHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$AllSpeedTestsWithResultsNotifier - = AsyncNotifier>; String _$speedTestRunNotifierHash() => r'76bfa7b486be1d8d9b2e3ed1c36b42f6ed9676b7'; diff --git a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart index aec338f..fdc6621 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart @@ -5,17 +5,12 @@ import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; class SpeedTestPopup extends ConsumerStatefulWidget { - /// The speed test configuration (use this OR [speedTestWithResults]) + /// The speed test configuration final SpeedTestConfig? cachedTest; - /// The joined speed test with results (use this OR [cachedTest]) - /// If provided, the config will be extracted from this - final SpeedTestWithResults? speedTestWithResults; - final VoidCallback? onCompleted; /// Callback when result should be submitted (auto-called when test passes) @@ -24,7 +19,6 @@ class SpeedTestPopup extends ConsumerStatefulWidget { const SpeedTestPopup({ super.key, this.cachedTest, - this.speedTestWithResults, this.onCompleted, this.onResultSubmitted, }); @@ -66,10 +60,8 @@ class _SpeedTestPopupState extends ConsumerState super.dispose(); } - /// Get the effective config from either cachedTest or speedTestWithResults - SpeedTestConfig? get _effectiveConfig { - return widget.cachedTest ?? widget.speedTestWithResults?.config; - } + /// Get the effective config + SpeedTestConfig? get _effectiveConfig => widget.cachedTest; double? _getMinDownload() => _effectiveConfig?.minDownloadMbps; double? _getMinUpload() => _effectiveConfig?.minUploadMbps; From 9ef87c85ed265fd97415ff0091cfb8f92597b052 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Wed, 21 Jan 2026 13:00:01 -0800 Subject: [PATCH 08/24] Speed test working for devices --- .../widgets/device_speed_test_section.dart | 25 ++++++++--- .../data/services/speed_test_service.dart | 21 ++++++--- .../domain/entities/speed_test_result.dart | 45 +++++++++++++++++-- .../widgets/speed_test_popup.dart | 29 ++++++------ 4 files changed, 92 insertions(+), 28 deletions(-) diff --git a/lib/features/devices/presentation/widgets/device_speed_test_section.dart b/lib/features/devices/presentation/widgets/device_speed_test_section.dart index bbba3fb..fba5caa 100644 --- a/lib/features/devices/presentation/widgets/device_speed_test_section.dart +++ b/lib/features/devices/presentation/widgets/device_speed_test_section.dart @@ -6,6 +6,7 @@ import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/widgets/widgets.dart'; import 'package:rgnets_fdk/features/devices/domain/constants/device_types.dart'; import 'package:rgnets_fdk/features/devices/domain/entities/device.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; @@ -112,13 +113,27 @@ class _DeviceSpeedTestSectionState Future _runSpeedTest() async { if (!mounted) return; - // Get adhoc config from cache final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); - final adhocConfig = cacheIntegration.getAdhocSpeedTestConfig(); - if (adhocConfig != null) { + // Try to get config from the device's existing results (uses the same test config) + SpeedTestConfig? config; + if (_deviceResults.isNotEmpty) { + final speedTestId = _deviceResults.first.speedTestId; + config = cacheIntegration.getSpeedTestConfigById(speedTestId); + if (config != null) { + LoggerService.info( + 'Running speed test for device ${_getPrefixedDeviceId()} with config from result: ${config.name} (id: $speedTestId)', + tag: 'DeviceSpeedTestSection', + ); + } + } + + // Fall back to adhoc config if no matching config found + config ??= cacheIntegration.getAdhocSpeedTestConfig(); + + if (config != null) { LoggerService.info( - 'Running speed test for device ${_getPrefixedDeviceId()} with adhoc config: ${adhocConfig.name}', + 'Running speed test for device ${_getPrefixedDeviceId()} with config: ${config.name}', tag: 'DeviceSpeedTestSection', ); } @@ -128,7 +143,7 @@ class _DeviceSpeedTestSectionState barrierDismissible: true, builder: (BuildContext context) { return SpeedTestPopup( - cachedTest: adhocConfig, + cachedTest: config, onCompleted: () { if (mounted) { // Reload results after test completion diff --git a/lib/features/speed_test/data/services/speed_test_service.dart b/lib/features/speed_test/data/services/speed_test_service.dart index 13a149f..901c3ed 100644 --- a/lib/features/speed_test/data/services/speed_test_service.dart +++ b/lib/features/speed_test/data/services/speed_test_service.dart @@ -132,10 +132,15 @@ class SpeedTestService { _statusMessageController.add(getMessage()); break; case 'completed': - _updateStatus(SpeedTestStatus.completed); - _statusMessageController.add(getMessage()); - _progress = 100.0; - _progressController.add(_progress); + // Don't set SpeedTestStatus.completed here - iperf3 sends 'completed' after + // each individual test (download/upload), but we want to wait until BOTH + // phases are done. The actual completion is handled in runSpeedTestWithFallback + // after both download and upload tests finish. + // Just update the message to show phase completion. + if (_isDownloadPhase) { + _statusMessageController.add('Download complete, starting upload...'); + } + // Don't set progress to 100% here either - that happens after the full test break; case 'cancelled': _updateStatus(SpeedTestStatus.idle); @@ -234,6 +239,9 @@ class SpeedTestService { _isRetryingFallback = true; // Enable fallback mode to suppress intermediate errors + // Capture test start time + final initiatedAt = DateTime.now(); + // Reset completed speeds from previous test _completedDownloadSpeed = 0.0; _completedUploadSpeed = 0.0; @@ -260,7 +268,7 @@ class SpeedTestService { try { // Attempt test with this server - final result = await _runTestWithServer(serverHost, localIp); + final result = await _runTestWithServer(serverHost, localIp, initiatedAt); if (result != null) { // Success! @@ -353,7 +361,7 @@ class SpeedTestService { /// Run test with a specific server, returns result or null if failed Future _runTestWithServer( - String serverHost, String? localIp) async { + String serverHost, String? localIp, DateTime initiatedAt) async { try { // Update the current server host being tested _serverHost = serverHost; @@ -417,6 +425,7 @@ class SpeedTestService { downloadMbps: (downloadSpeed as num).toDouble(), uploadMbps: (uploadSpeed as num).toDouble(), rtt: (latency as num).toDouble(), + initiatedAt: initiatedAt, completedAt: DateTime.now(), localIpAddress: localIp, serverHost: serverHost, diff --git a/lib/features/speed_test/domain/entities/speed_test_result.dart b/lib/features/speed_test/domain/entities/speed_test_result.dart index ace229b..268c408 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.dart @@ -93,17 +93,21 @@ class SpeedTestResult with _$SpeedTestResult { /// Pre-process JSON to detect and correct swapped download/upload values static Map _preprocessJson(Map json) { - final normalizedJson = _normalizeTestedViaAccessPointId(json); + // First extract IDs from nested association objects + var normalizedJson = _extractNestedAssociationIds(json); + // Then normalize any string IDs to ints + normalizedJson = _normalizeTestedViaAccessPointId(normalizedJson); + final download = _parseDecimal(normalizedJson['download_mbps']); final upload = _parseDecimal(normalizedJson['upload_mbps']); if (download == null || upload == null) { - return json; + return normalizedJson; } // Both are 0 - likely incomplete test, don't swap if (download == 0 && upload == 0) { - return json; + return normalizedJson; } bool shouldSwap = false; @@ -138,6 +142,41 @@ class SpeedTestResult with _$SpeedTestResult { return normalizedJson; } + /// Extract IDs from nested association objects + /// RESTFramework sends associations as objects like: + /// "tested_via_access_point": { "id": 1309, "name": "..." } + /// instead of: + /// "tested_via_access_point_id": 1309 + static Map _extractNestedAssociationIds( + Map json, + ) { + final result = Map.from(json); + + // Map of association name to the corresponding _id field + const associationMappings = { + 'tested_via_access_point': 'tested_via_access_point_id', + 'tested_via_media_converter': 'tested_via_media_converter_id', + 'speed_test': 'speed_test_id', + 'pms_room': 'pms_room_id', + 'access_point': 'access_point_id', + }; + + for (final entry in associationMappings.entries) { + final associationKey = entry.key; + final idKey = entry.value; + + // Only extract if the _id field is not already set + if (result[idKey] == null && result[associationKey] is Map) { + final association = result[associationKey] as Map; + if (association['id'] != null) { + result[idKey] = association['id']; + } + } + } + + return result; + } + static Map _normalizeTestedViaAccessPointId( Map json, ) { diff --git a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart index fdc6621..779a6c5 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart @@ -103,6 +103,7 @@ class _SpeedTestPopupState extends ConsumerState serverHost: testState.serverHost, speedTestId: _effectiveConfig?.id, passed: testState.testPassed ?? false, + initiatedAt: result.initiatedAt, completedAt: DateTime.now(), testType: 'iperf3', source: testState.localIpAddress, @@ -202,22 +203,22 @@ class _SpeedTestPopupState extends ConsumerState color: AppColors.gray500, ), ), - if (minRequired != null) ...[ - const SizedBox(height: 2), - SizedBox( - width: 110, - child: Text( - 'Min: ${_formatSpeed(minRequired)}', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 8, - color: AppColors.gray400, - fontStyle: FontStyle.italic, - fontFamily: 'monospace', - ), + const SizedBox(height: 2), + SizedBox( + width: 110, + child: Text( + minRequired != null + ? 'Min: ${_formatSpeed(minRequired)}' + : 'Min: Not set', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 8, + color: AppColors.gray400, + fontStyle: FontStyle.italic, + fontFamily: 'monospace', ), ), - ], + ), ], ), ); From 91264d878cb790bccf3d5f3df58ccca8870926a6 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Wed, 21 Jan 2026 13:58:54 -0800 Subject: [PATCH 09/24] Add pms speed test --- assets/speed_test_indicator_img/coverage.png | Bin 0 -> 13715 bytes .../validation_ap.png | Bin 0 -> 111435 bytes .../validation_ont.png | Bin 0 -> 12139 bytes .../screens/room_detail_screen.dart | 13 + .../widgets/room_speed_test_selector.dart | 901 ++++++++++++++++++ pubspec.yaml | 5 + 6 files changed, 919 insertions(+) create mode 100644 assets/speed_test_indicator_img/coverage.png create mode 100644 assets/speed_test_indicator_img/validation_ap.png create mode 100644 assets/speed_test_indicator_img/validation_ont.png create mode 100644 lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart diff --git a/assets/speed_test_indicator_img/coverage.png b/assets/speed_test_indicator_img/coverage.png new file mode 100644 index 0000000000000000000000000000000000000000..81bb82af8d2975936589a09d26a2183fe7277459 GIT binary patch literal 13715 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 TXoLS{MP+7eWmJFDEz|+QU literal 0 HcmV?d00001 diff --git a/assets/speed_test_indicator_img/validation_ap.png b/assets/speed_test_indicator_img/validation_ap.png new file mode 100644 index 0000000000000000000000000000000000000000..25113e663d16e759055c24db224d70f079ad35eb GIT binary patch literal 111435 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 literal 0 HcmV?d00001 diff --git a/assets/speed_test_indicator_img/validation_ont.png b/assets/speed_test_indicator_img/validation_ont.png new file mode 100644 index 0000000000000000000000000000000000000000..4d05821a043dd2989248df25bd9ff59d0a0d7a05 GIT binary patch literal 12139 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

m#f-yg>m*Q4Wn@mA381-nlQQ2)VDo2 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 WuQxMyGlJ=Ph=Hz&&PQ$Z!~X;P|8o=o literal 0 HcmV?d00001 diff --git a/lib/features/rooms/presentation/screens/room_detail_screen.dart b/lib/features/rooms/presentation/screens/room_detail_screen.dart index a8f1dbf..8183ace 100644 --- a/lib/features/rooms/presentation/screens/room_detail_screen.dart +++ b/lib/features/rooms/presentation/screens/room_detail_screen.dart @@ -10,6 +10,7 @@ import 'package:rgnets_fdk/features/room_readiness/domain/entities/room_readines import 'package:rgnets_fdk/features/rooms/presentation/providers/room_device_view_model.dart'; import 'package:rgnets_fdk/features/rooms/presentation/providers/room_view_models.dart'; import 'package:rgnets_fdk/features/rooms/presentation/providers/rooms_riverpod_provider.dart'; +import 'package:rgnets_fdk/features/speed_test/presentation/widgets/room_speed_test_selector.dart'; /// Room detail screen with device management class RoomDetailScreen extends ConsumerStatefulWidget { @@ -310,7 +311,11 @@ class _RoomHeader extends StatelessWidget { } } +<<<<<<< HEAD class _OverviewTab extends ConsumerWidget { +======= +class _OverviewTab extends StatelessWidget { +>>>>>>> 24906fa (Add pms speed test) const _OverviewTab({required this.roomVm}); final RoomViewModel roomVm; @@ -325,6 +330,14 @@ class _OverviewTab extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Speed Test Results for this room + RoomSpeedTestSelector( + pmsRoomId: roomVm.room.id, + roomName: roomVm.name, + apIds: const [], // TODO: Get AP IDs from room devices + ), + const SizedBox(height: 16), + // Room Information _SectionCard( title: 'Room Information', diff --git a/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart b/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart new file mode 100644 index 0000000..d3f0141 --- /dev/null +++ b/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart @@ -0,0 +1,901 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; +import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/core/theme/app_colors.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; + +/// Helper class to group test configuration with its results +class SpeedTestWithResults { + final SpeedTestConfig config; + final List results; + + SpeedTestWithResults({ + required this.config, + required this.results, + }); +} + +/// Widget that displays speed test results for a PMS room with a dropdown selector. +/// +/// Shows a card with a dropdown to select from available speed test results, +/// grouped by test configuration. Each result displays pass/fail status and metrics. +class RoomSpeedTestSelector extends ConsumerStatefulWidget { + final int pmsRoomId; + final String roomName; + final String? roomType; + final List apIds; + + const RoomSpeedTestSelector({ + super.key, + required this.pmsRoomId, + required this.roomName, + this.roomType, + this.apIds = const [], + }); + + @override + ConsumerState createState() => + _RoomSpeedTestSelectorState(); +} + +class _RoomSpeedTestSelectorState extends ConsumerState { + List _speedTests = []; + bool _isLoading = true; + String? _errorMessage; + SpeedTestResult? _selectedResult; + + @override + void initState() { + super.initState(); + _loadSpeedTests(); + } + + Future _loadSpeedTests() async { + if (!mounted) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + + // Get all speed test results from cache + final allResults = cacheIntegration.getCachedSpeedTestResults(); + + LoggerService.info( + 'Loading speed tests for room ${widget.pmsRoomId}: Found ${allResults.length} total results', + tag: 'RoomSpeedTestSelector', + ); + + if (allResults.isEmpty) { + setState(() { + _speedTests = []; + _selectedResult = null; + _isLoading = false; + }); + return; + } + + // Filter results by pms_room_id and group by speed_test_id + final Map> resultsByTestId = {}; + + for (final result in allResults) { + // Only include results for this room + if (result.pmsRoomId != widget.pmsRoomId) continue; + + final speedTestId = result.speedTestId; + if (speedTestId == null) continue; + + resultsByTestId.putIfAbsent(speedTestId, () => []); + resultsByTestId[speedTestId]!.add(result); + } + + LoggerService.info( + 'Filtered results for room ${widget.pmsRoomId}: ${resultsByTestId.length} unique tests', + tag: 'RoomSpeedTestSelector', + ); + + if (resultsByTestId.isEmpty) { + setState(() { + _speedTests = []; + _selectedResult = null; + _isLoading = false; + }); + return; + } + + // Get speed test configurations + final configs = cacheIntegration.getCachedSpeedTestConfigs(); + final Map configsById = {}; + for (final config in configs) { + if (config.id != null) { + configsById[config.id!] = config; + } + } + + // Build SpeedTestWithResults for each speed_test_id + final List speedTestsWithResults = []; + + for (final entry in resultsByTestId.entries) { + final speedTestId = entry.key; + final results = entry.value; + + // Sort results by timestamp (most recent first) + results.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + // Get the config + final config = configsById[speedTestId]; + if (config == null) continue; + + speedTestsWithResults.add(SpeedTestWithResults( + config: config, + results: results, + )); + } + + // Restore selection if possible + SpeedTestResult? updatedSelection; + if (_selectedResult != null) { + for (final group in speedTestsWithResults) { + for (final result in group.results) { + if (result.id == _selectedResult!.id) { + updatedSelection = result; + break; + } + } + if (updatedSelection != null) break; + } + } + + setState(() { + _speedTests = speedTestsWithResults; + if (updatedSelection != null) { + _selectedResult = updatedSelection; + } else if (speedTestsWithResults.isNotEmpty && + speedTestsWithResults.first.results.isNotEmpty) { + _selectedResult = speedTestsWithResults.first.results.first; + } + _isLoading = false; + }); + } catch (e) { + LoggerService.error( + 'Failed to load speed tests: $e', + tag: 'RoomSpeedTestSelector', + ); + setState(() { + _errorMessage = 'Failed to load speed tests: $e'; + _speedTests = []; + _selectedResult = null; + _isLoading = false; + }); + } + } + + bool _metricMeetsThreshold(double? value, double? minRequired) { + if (minRequired == null) return true; + if (value == null) return false; + return value >= minRequired; + } + + bool _resultPassesForConfig(SpeedTestResult result, SpeedTestConfig config) { + final downloadPass = + _metricMeetsThreshold(result.downloadMbps, config.minDownloadMbps); + final uploadPass = + _metricMeetsThreshold(result.uploadMbps, config.minUploadMbps); + + return result.passed || (downloadPass && uploadPass); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Card( + margin: const EdgeInsets.all(16), + color: AppColors.cardDark, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Loading speed tests...', + style: TextStyle(color: AppColors.textSecondary), + ), + ], + ), + ), + ); + } + + if (_errorMessage != null) { + return Card( + margin: const EdgeInsets.all(16), + color: AppColors.error.withValues(alpha: 0.15), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.error, color: AppColors.error), + const SizedBox(width: 12), + Expanded( + child: Text( + _errorMessage!, + style: TextStyle(color: AppColors.error), + ), + ), + ], + ), + ), + ); + } + + if (_speedTests.isEmpty) { + return Card( + margin: const EdgeInsets.all(16), + color: AppColors.cardDark, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.info_outline, color: AppColors.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'No speed test results for this room', + style: TextStyle(color: AppColors.textSecondary), + ), + ), + IconButton( + icon: Icon(Icons.refresh, color: AppColors.textSecondary), + tooltip: 'Refresh', + onPressed: _loadSpeedTests, + ), + ], + ), + ), + ); + } + + return _buildUnifiedSpeedTestSelector(); + } + + Widget _buildUnifiedSpeedTestSelector() { + // Collect all results with their config + List> allResultsWithConfig = []; + + for (final testWithResults in _speedTests) { + for (final result in testWithResults.results) { + final bool passed = + _resultPassesForConfig(result, testWithResults.config); + allResultsWithConfig.add({ + 'result': result, + 'config': testWithResults.config, + 'passed': passed, + }); + } + } + + // Sort: passed first, then by most recent + allResultsWithConfig.sort((a, b) { + final bool aPassed = a['passed'] as bool? ?? false; + final bool bPassed = b['passed'] as bool? ?? false; + + if (aPassed && !bPassed) return -1; + if (!aPassed && bPassed) return 1; + + final aTime = (a['result'] as SpeedTestResult).timestamp; + final bTime = (b['result'] as SpeedTestResult).timestamp; + return bTime.compareTo(aTime); + }); + + final Map selectedEntry = (_selectedResult != null) + ? allResultsWithConfig.firstWhere( + (item) => + (item['result'] as SpeedTestResult).id == _selectedResult!.id, + orElse: () => allResultsWithConfig.first, + ) + : allResultsWithConfig.first; + + final currentResult = selectedEntry['result'] as SpeedTestResult; + final selectedConfig = selectedEntry['config'] as SpeedTestConfig; + final bool selectedPassed = selectedEntry['passed'] as bool? ?? + _resultPassesForConfig(currentResult, selectedConfig); + final String resultLabel = + (currentResult.roomType != null && currentResult.roomType!.isNotEmpty) + ? currentResult.roomType! + : 'Result #${currentResult.id?.toString() ?? "-"}'; + + return Card( + margin: const EdgeInsets.all(16), + color: AppColors.cardDark, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon(Icons.speed, color: AppColors.primary), + const SizedBox(width: 8), + Text( + 'Speed Test Results', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const Spacer(), + IconButton( + icon: Icon(Icons.refresh, size: 20, color: AppColors.textSecondary), + tooltip: 'Refresh', + onPressed: _loadSpeedTests, + ), + ], + ), + const SizedBox(height: 16), + + // Dropdown button + OutlinedButton( + onPressed: () async { + final selected = + await _showAllResultsModal(allResultsWithConfig); + if (selected != null) { + setState(() { + _selectedResult = selected; + }); + } + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + side: BorderSide(color: AppColors.gray600), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Select Result (${allResultsWithConfig.length} total)', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + resultLabel, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + !currentResult.isApplicable + ? Icons.remove_circle_outline + : (selectedPassed ? Icons.check_circle : Icons.cancel), + size: 24, + color: !currentResult.isApplicable + ? AppColors.textSecondary + : (selectedPassed ? AppColors.success : AppColors.error), + ), + const SizedBox(width: 4), + Icon( + Icons.arrow_drop_down, + color: AppColors.textSecondary, + ), + ], + ), + ), + + const SizedBox(height: 16), + _buildResultDetails(currentResult, selectedConfig), + + const SizedBox(height: 16), + + // Run test button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + int? testedViaAccessPointId = + currentResult.testedViaAccessPointId; + if (testedViaAccessPointId == null && + widget.apIds.isNotEmpty) { + testedViaAccessPointId = widget.apIds.first; + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => SpeedTestPopup( + cachedTest: selectedConfig, + onCompleted: () { + _loadSpeedTests(); + }, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Run Test'), + ), + ), + ], + ), + ), + ); + } + + String _capitalizeEachWord(String text) { + return text.split(' ').map((word) { + if (word.isEmpty) return word; + return word[0].toUpperCase() + word.substring(1).toLowerCase(); + }).join(' '); + } + + String? _getSpeedTestIcon(String testName) { + final lowerName = testName.toLowerCase(); + + if (lowerName.contains('coverage')) { + return 'assets/speed_test_indicator_img/coverage.png'; + } else if (lowerName.contains('ont')) { + return 'assets/speed_test_indicator_img/validation_ont.png'; + } else if (lowerName.contains('access point') || lowerName.contains('ap')) { + return 'assets/speed_test_indicator_img/validation_ap.png'; + } + + return null; + } + + Future _showAllResultsModal( + List> allResults) async { + // Group results by speed test config + Map>> groupedResults = {}; + for (final item in allResults) { + final config = item['config'] as SpeedTestConfig; + if (config.id == null) continue; + groupedResults.putIfAbsent(config.id!, () => []); + groupedResults[config.id!]!.add(item); + } + + // Build list of widgets with headers + List listItems = []; + + groupedResults.forEach((speedTestId, items) { + final config = items.first['config'] as SpeedTestConfig; + final testName = config.name ?? 'Speed Test #$speedTestId'; + final iconPath = _getSpeedTestIcon(testName); + + // Section header + listItems.add( + Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + iconPath != null + ? Image.asset( + iconPath, + width: 40, + height: 40, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Icon(Icons.speed, + size: 36, color: AppColors.textSecondary); + }, + ) + : Icon(Icons.speed, size: 36, color: AppColors.textSecondary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _capitalizeEachWord(testName), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + if (config.minDownloadMbps != null || + config.minUploadMbps != null) + Text( + 'Min: ${config.minDownloadMbps?.toStringAsFixed(0) ?? "?"} Mbps down / ${config.minUploadMbps?.toStringAsFixed(0) ?? "?"} Mbps up', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + ); + + // Result items + for (final item in items) { + final result = item['result'] as SpeedTestResult; + final isSelected = result.id == _selectedResult?.id; + final bool passed = + item['passed'] as bool? ?? _resultPassesForConfig(result, config); + + String displayText = ''; + if (result.roomType != null && result.roomType!.isNotEmpty) { + displayText = result.roomType!; + } else { + displayText = config.name ?? 'Result #${result.id}'; + } + + listItems.add( + ListTile( + selected: isSelected, + selectedTileColor: AppColors.primary.withValues(alpha: 0.15), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: !result.isApplicable + ? AppColors.gray500 + : (passed ? AppColors.success : AppColors.error), + ), + ), + title: Text( + displayText, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: 14, + color: AppColors.textPrimary, + ), + ), + subtitle: !result.isApplicable + ? Text( + 'Not Applicable', + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + fontStyle: FontStyle.italic, + ), + ) + : (result.downloadMbps != null + ? Text( + '↓ ${result.downloadMbps!.toStringAsFixed(1)} Mbps ↑ ${result.uploadMbps?.toStringAsFixed(1) ?? "N/A"} Mbps', + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + ), + ) + : null), + trailing: Icon( + !result.isApplicable + ? Icons.remove_circle_outline + : (passed ? Icons.check_circle : Icons.cancel), + size: 28, + color: !result.isApplicable + ? AppColors.textSecondary + : (passed ? AppColors.success : AppColors.error), + ), + onTap: () { + Navigator.pop(context, result); + }, + ), + ); + } + + // Separator + listItems.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Divider( + height: 1, + thickness: 1.5, + color: AppColors.gray700, + ), + ), + ); + }); + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.6, + maxChildSize: 0.9, + minChildSize: 0.4, + expand: false, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceDark, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Handle bar + Container( + margin: const EdgeInsets.only(top: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.gray600, + borderRadius: BorderRadius.circular(2), + ), + ), + // Header + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.history, color: AppColors.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Select Result (${allResults.length} total)', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ), + IconButton( + icon: Icon(Icons.close, color: AppColors.textSecondary), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + Divider(height: 1, color: AppColors.gray700), + // Results list + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: listItems.length, + itemBuilder: (context, index) => listItems[index], + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _buildResultDetails(SpeedTestResult result, SpeedTestConfig config) { + final downloadPassed = + _metricMeetsThreshold(result.downloadMbps, config.minDownloadMbps); + final uploadPassed = + _metricMeetsThreshold(result.uploadMbps, config.minUploadMbps); + final bool passed = _resultPassesForConfig(result, config); + + final bgColor = !result.isApplicable + ? AppColors.gray800 + : (passed ? AppColors.success.withValues(alpha: 0.15) : AppColors.error.withValues(alpha: 0.15)); + final borderColor = !result.isApplicable + ? AppColors.gray700 + : (passed ? AppColors.success.withValues(alpha: 0.4) : AppColors.error.withValues(alpha: 0.4)); + final statusColor = !result.isApplicable + ? AppColors.textSecondary + : (passed ? AppColors.success : AppColors.error); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + !result.isApplicable + ? Icons.remove_circle_outline + : (passed ? Icons.check_circle : Icons.cancel), + color: statusColor, + size: 20, + ), + const SizedBox(width: 8), + Text( + !result.isApplicable + ? 'Not Applicable' + : (passed ? 'Passed' : 'Failed'), + style: TextStyle( + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ), + if (result.completedAt != null) + Text( + _formatTimeSince(result.completedAt!), + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Metrics row + Row( + children: [ + Expanded( + child: _buildMetric( + 'Download', + result.downloadMbps != null + ? '${result.downloadMbps!.toStringAsFixed(1)} Mbps' + : 'N/A', + Icons.download, + passed: result.isApplicable ? downloadPassed : null, + minRequired: config.minDownloadMbps, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildMetric( + 'Upload', + result.uploadMbps != null + ? '${result.uploadMbps!.toStringAsFixed(1)} Mbps' + : 'N/A', + Icons.upload, + passed: result.isApplicable ? uploadPassed : null, + minRequired: config.minUploadMbps, + ), + ), + ], + ), + + // Latency row + if (result.rtt != null || result.jitter != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + if (result.rtt != null) + Expanded( + child: _buildMetric( + 'Latency', + '${result.rtt!.toStringAsFixed(1)} ms', + Icons.timer, + ), + ), + if (result.rtt != null && result.jitter != null) + const SizedBox(width: 12), + if (result.jitter != null) + Expanded( + child: _buildMetric( + 'Jitter', + '${result.jitter!.toStringAsFixed(1)} ms', + Icons.show_chart, + ), + ), + ], + ), + ], + + // Server info + if (config.target != null) ...[ + const SizedBox(height: 8), + Text( + 'Server: ${config.target}:${config.port ?? 5201}', + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ], + ), + ); + } + + Widget _buildMetric(String label, String value, IconData icon, + {bool? passed, double? minRequired}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: AppColors.textSecondary), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + if (passed != null) ...[ + const SizedBox(width: 4), + Icon( + passed ? Icons.check_circle : Icons.cancel, + size: 14, + color: passed ? AppColors.success : AppColors.error, + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + if (minRequired != null) + Text( + 'Min: ${minRequired.toStringAsFixed(0)} Mbps', + style: TextStyle( + fontSize: 10, + color: AppColors.textSecondary, + fontStyle: FontStyle.italic, + ), + ), + ], + ); + } + + String _formatTimeSince(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inDays > 0) { + return '${difference.inDays}d ago'; + } else if (difference.inHours > 0) { + return '${difference.inHours}h ago'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes}m ago'; + } else { + return 'Just now'; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 7032461..997381a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -123,8 +123,13 @@ flutter: # MAC address database for OUI lookup - assets/mac_unified.csv +<<<<<<< HEAD # Configuration files - assets/config/onboarding_messages.json +======= + # Speed test indicator images + - assets/speed_test_indicator_img/ +>>>>>>> 24906fa (Add pms speed test) # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images From a169e4d1d6b2daa80be1aba992f1fa1d351d701b Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Wed, 21 Jan 2026 14:42:54 -0800 Subject: [PATCH 10/24] Added mark applicable fix submission issue where it created a new result --- .../speed_test_websocket_data_source.dart | 70 ++++- .../domain/entities/speed_test_result.dart | 6 +- .../entities/speed_test_result.freezed.dart | 44 ++-- .../domain/entities/speed_test_result.g.dart | 2 - .../providers/speed_test_providers.dart | 14 +- .../providers/speed_test_providers.g.dart | 2 +- .../widgets/room_speed_test_selector.dart | 184 ++++++++++--- .../widgets/speed_test_popup.dart | 242 +++++++++++++++++- 8 files changed, 497 insertions(+), 67 deletions(-) diff --git a/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart b/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart index 08cd11c..1fd1252 100644 --- a/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart +++ b/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart @@ -210,9 +210,37 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { throw StateError('WebSocket not connected'); } - final jsonToSend = result.toJson(); + // Build params with only fields the backend accepts (same format as adhoc submission) + final params = { + if (result.speedTestId != null) 'speed_test_id': result.speedTestId, + if (result.downloadMbps != null) 'download_mbps': result.downloadMbps, + if (result.uploadMbps != null) 'upload_mbps': result.uploadMbps, + if (result.rtt != null) 'rtt': result.rtt, + if (result.jitter != null) 'jitter': result.jitter, + if (result.packetLoss != null) 'packet_loss': result.packetLoss, + 'passed': result.passed, + 'is_applicable': result.isApplicable, + if (result.initiatedAt != null) 'initiated_at': result.initiatedAt!.toIso8601String(), + if (result.completedAt != null) 'completed_at': result.completedAt!.toIso8601String(), + if (result.testType != null) 'test_type': result.testType, + if (result.source != null) 'source': result.source, + if (result.destination != null) 'destination': result.destination, + if (result.port != null) 'port': result.port, + if (result.iperfProtocol != null) 'iperf_protocol': result.iperfProtocol, + if (result.accessPointId != null) 'access_point_id': result.accessPointId, + if (result.testedViaAccessPointId != null) 'tested_via_access_point_id': result.testedViaAccessPointId, + if (result.testedViaAccessPointRadioId != null) 'tested_via_access_point_radio_id': result.testedViaAccessPointRadioId, + if (result.testedViaMediaConverterId != null) 'tested_via_media_converter_id': result.testedViaMediaConverterId, + if (result.uplinkId != null) 'uplink_id': result.uplinkId, + if (result.wlanId != null) 'wlan_id': result.wlanId, + if (result.pmsRoomId != null) 'pms_room_id': result.pmsRoomId, + if (result.roomType != null) 'room_type': result.roomType, + if (result.note != null) 'note': result.note, + if (result.raw != null) 'raw': result.raw, + }; + LoggerService.info( - 'createSpeedTestResult sending: $jsonToSend', + 'createSpeedTestResult sending: $params', tag: 'SpeedTestWS', ); @@ -220,7 +248,7 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { action: 'create_resource', resourceType: _speedTestResultResourceType, additionalData: { - 'params': jsonToSend, + 'params': params, }, timeout: const Duration(seconds: 15), ); @@ -257,12 +285,46 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { throw ArgumentError('Cannot update speed test result without id'); } + // Build params with only fields the backend accepts + final params = { + if (result.speedTestId != null) 'speed_test_id': result.speedTestId, + if (result.downloadMbps != null) 'download_mbps': result.downloadMbps, + if (result.uploadMbps != null) 'upload_mbps': result.uploadMbps, + if (result.rtt != null) 'rtt': result.rtt, + if (result.jitter != null) 'jitter': result.jitter, + if (result.packetLoss != null) 'packet_loss': result.packetLoss, + 'passed': result.passed, + 'is_applicable': result.isApplicable, + if (result.initiatedAt != null) 'initiated_at': result.initiatedAt!.toIso8601String(), + if (result.completedAt != null) 'completed_at': result.completedAt!.toIso8601String(), + if (result.testType != null) 'test_type': result.testType, + if (result.source != null) 'source': result.source, + if (result.destination != null) 'destination': result.destination, + if (result.port != null) 'port': result.port, + if (result.iperfProtocol != null) 'iperf_protocol': result.iperfProtocol, + if (result.accessPointId != null) 'access_point_id': result.accessPointId, + if (result.testedViaAccessPointId != null) 'tested_via_access_point_id': result.testedViaAccessPointId, + if (result.testedViaAccessPointRadioId != null) 'tested_via_access_point_radio_id': result.testedViaAccessPointRadioId, + if (result.testedViaMediaConverterId != null) 'tested_via_media_converter_id': result.testedViaMediaConverterId, + if (result.uplinkId != null) 'uplink_id': result.uplinkId, + if (result.wlanId != null) 'wlan_id': result.wlanId, + if (result.pmsRoomId != null) 'pms_room_id': result.pmsRoomId, + if (result.roomType != null) 'room_type': result.roomType, + if (result.note != null) 'note': result.note, + if (result.raw != null) 'raw': result.raw, + }; + + LoggerService.info( + 'updateSpeedTestResult sending: $params', + tag: 'SpeedTestWS', + ); + final response = await _webSocketService.requestActionCable( action: 'update_resource', resourceType: _speedTestResultResourceType, additionalData: { 'id': result.id, - 'params': result.toJson(), + 'params': params, }, timeout: const Duration(seconds: 15), ); diff --git a/lib/features/speed_test/domain/entities/speed_test_result.dart b/lib/features/speed_test/domain/entities/speed_test_result.dart index 268c408..2f22b89 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.dart @@ -61,9 +61,9 @@ class SpeedTestResult with _$SpeedTestResult { @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - // Legacy fields for backwards compatibility - @Default(false) bool hasError, - String? errorMessage, + // Legacy fields for backwards compatibility (not sent to server) + @JsonKey(includeToJson: false) @Default(false) bool hasError, + @JsonKey(name: 'error_message', includeToJson: false) String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost, }) = _SpeedTestResult; diff --git a/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart b/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart index 9eedc6a..05b5940 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart @@ -80,8 +80,10 @@ mixin _$SpeedTestResult { DateTime? get createdAt => throw _privateConstructorUsedError; @JsonKey(name: 'updated_at') DateTime? get updatedAt => - throw _privateConstructorUsedError; // Legacy fields for backwards compatibility + throw _privateConstructorUsedError; // Legacy fields for backwards compatibility (not sent to server) + @JsonKey(includeToJson: false) bool get hasError => throw _privateConstructorUsedError; + @JsonKey(name: 'error_message', includeToJson: false) String? get errorMessage => throw _privateConstructorUsedError; @JsonKey(name: 'local_ip_address') String? get localIpAddress => throw _privateConstructorUsedError; @@ -130,7 +132,8 @@ mixin _$SpeedTestResult { @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - bool hasError, + @JsonKey(includeToJson: false) bool hasError, + @JsonKey(name: 'error_message', includeToJson: false) String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost) @@ -180,7 +183,8 @@ mixin _$SpeedTestResult { @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - bool hasError, + @JsonKey(includeToJson: false) bool hasError, + @JsonKey(name: 'error_message', includeToJson: false) String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost)? @@ -230,7 +234,8 @@ mixin _$SpeedTestResult { @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - bool hasError, + @JsonKey(includeToJson: false) bool hasError, + @JsonKey(name: 'error_message', includeToJson: false) String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost)? @@ -303,7 +308,8 @@ abstract class $SpeedTestResultCopyWith<$Res> { @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - bool hasError, + @JsonKey(includeToJson: false) bool hasError, + @JsonKey(name: 'error_message', includeToJson: false) String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost}); @@ -558,7 +564,8 @@ abstract class _$$SpeedTestResultImplCopyWith<$Res> @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - bool hasError, + @JsonKey(includeToJson: false) bool hasError, + @JsonKey(name: 'error_message', includeToJson: false) String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost}); @@ -806,8 +813,8 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @JsonKey(name: 'updated_by') this.updatedBy, @JsonKey(name: 'created_at') this.createdAt, @JsonKey(name: 'updated_at') this.updatedAt, - this.hasError = false, - this.errorMessage, + @JsonKey(includeToJson: false) this.hasError = false, + @JsonKey(name: 'error_message', includeToJson: false) this.errorMessage, @JsonKey(name: 'local_ip_address') this.localIpAddress, @JsonKey(name: 'server_host') this.serverHost}) : super._(); @@ -909,11 +916,12 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @override @JsonKey(name: 'updated_at') final DateTime? updatedAt; -// Legacy fields for backwards compatibility +// Legacy fields for backwards compatibility (not sent to server) @override - @JsonKey() + @JsonKey(includeToJson: false) final bool hasError; @override + @JsonKey(name: 'error_message', includeToJson: false) final String? errorMessage; @override @JsonKey(name: 'local_ip_address') @@ -1094,7 +1102,8 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - bool hasError, + @JsonKey(includeToJson: false) bool hasError, + @JsonKey(name: 'error_message', includeToJson: false) String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost) @@ -1184,7 +1193,8 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - bool hasError, + @JsonKey(includeToJson: false) bool hasError, + @JsonKey(name: 'error_message', includeToJson: false) String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost)? @@ -1274,7 +1284,8 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - bool hasError, + @JsonKey(includeToJson: false) bool hasError, + @JsonKey(name: 'error_message', includeToJson: false) String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost)? @@ -1402,7 +1413,8 @@ abstract class _SpeedTestResult extends SpeedTestResult { @JsonKey(name: 'updated_by') final String? updatedBy, @JsonKey(name: 'created_at') final DateTime? createdAt, @JsonKey(name: 'updated_at') final DateTime? updatedAt, - final bool hasError, + @JsonKey(includeToJson: false) final bool hasError, + @JsonKey(name: 'error_message', includeToJson: false) final String? errorMessage, @JsonKey(name: 'local_ip_address') final String? localIpAddress, @JsonKey(name: 'server_host') @@ -1505,9 +1517,11 @@ abstract class _SpeedTestResult extends SpeedTestResult { @override @JsonKey(name: 'updated_at') DateTime? get updatedAt; - @override // Legacy fields for backwards compatibility + @override // Legacy fields for backwards compatibility (not sent to server) + @JsonKey(includeToJson: false) bool get hasError; @override + @JsonKey(name: 'error_message', includeToJson: false) String? get errorMessage; @override @JsonKey(name: 'local_ip_address') diff --git a/lib/features/speed_test/domain/entities/speed_test_result.g.dart b/lib/features/speed_test/domain/entities/speed_test_result.g.dart index 2304093..3bb6318 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.g.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.g.dart @@ -102,8 +102,6 @@ Map _$$SpeedTestResultImplToJson( writeNotNull('updated_by', instance.updatedBy); writeNotNull('created_at', instance.createdAt?.toIso8601String()); writeNotNull('updated_at', instance.updatedAt?.toIso8601String()); - val['has_error'] = instance.hasError; - writeNotNull('error_message', instance.errorMessage); writeNotNull('local_ip_address', instance.localIpAddress); writeNotNull('server_host', instance.serverHost); return val; diff --git a/lib/features/speed_test/presentation/providers/speed_test_providers.dart b/lib/features/speed_test/presentation/providers/speed_test_providers.dart index 2615e8b..fa78bb7 100644 --- a/lib/features/speed_test/presentation/providers/speed_test_providers.dart +++ b/lib/features/speed_test/presentation/providers/speed_test_providers.dart @@ -321,10 +321,10 @@ class SpeedTestRunNotifier extends _$SpeedTestRunNotifier { _syncConfigFromService(); } - /// Submit result to API (for config-based tests) - /// Returns true if submission succeeded - Future submitResult({int? accessPointId}) async { - if (state.completedResult == null) return false; + /// Submit result via WebSocket (for config-based tests) + /// Returns the created result if submission succeeded, null otherwise + Future submitResult({int? accessPointId}) async { + if (state.completedResult == null) return null; final result = state.completedResult!.copyWith( speedTestId: state.config?.id, @@ -335,16 +335,16 @@ class SpeedTestRunNotifier extends _$SpeedTestRunNotifier { ); try { - await ref + final created = await ref .read(speedTestResultsNotifierProvider( speedTestId: state.config?.id, accessPointId: accessPointId, ).notifier) .createResult(result); - return true; + return created; } catch (e) { state = state.copyWith(errorMessage: 'Submission failed: $e'); - return false; + return null; } } diff --git a/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart b/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart index 3b2f519..05e51a0 100644 --- a/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart +++ b/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart @@ -249,7 +249,7 @@ class _SpeedTestResultsNotifierProviderElement } String _$speedTestRunNotifierHash() => - r'76bfa7b486be1d8d9b2e3ed1c36b42f6ed9676b7'; + r'83cb66e1211ab3f3b1f5a9f72b96c8189b0b8cb5'; /// See also [SpeedTestRunNotifier]. @ProviderFor(SpeedTestRunNotifier) diff --git a/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart b/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart index d3f0141..a7bf337 100644 --- a/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart +++ b/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart @@ -5,6 +5,7 @@ import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; /// Helper class to group test configuration with its results @@ -44,6 +45,7 @@ class RoomSpeedTestSelector extends ConsumerStatefulWidget { class _RoomSpeedTestSelectorState extends ConsumerState { List _speedTests = []; bool _isLoading = true; + bool _isUpdating = false; String? _errorMessage; SpeedTestResult? _selectedResult; @@ -191,6 +193,81 @@ class _RoomSpeedTestSelectorState extends ConsumerState { return result.passed || (downloadPass && uploadPass); } + Future _toggleApplicable(SpeedTestResult result) async { + if (_isUpdating) return; + + setState(() { + _isUpdating = true; + }); + + try { + // Create updated result with toggled isApplicable + final updatedResult = result.copyWith( + isApplicable: !result.isApplicable, + ); + + LoggerService.info( + 'Toggling isApplicable for result ${result.id}: ' + '${result.isApplicable} -> ${!result.isApplicable}', + tag: 'RoomSpeedTestSelector', + ); + + // Update via provider + final notifier = ref.read( + speedTestResultsNotifierProvider( + speedTestId: result.speedTestId, + ).notifier, + ); + + final updated = await notifier.updateResult(updatedResult); + + if (updated != null) { + // Update the cache so _loadSpeedTests sees the new value + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + cacheIntegration.updateSpeedTestResultInCache(updated); + + // Update the selected result locally + setState(() { + _selectedResult = updated; + }); + // Reload to get fresh data from cache + await _loadSpeedTests(); + } else { + LoggerService.error( + 'Failed to update result ${result.id}', + tag: 'RoomSpeedTestSelector', + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update result'), + backgroundColor: AppColors.error, + ), + ); + } + } + } catch (e) { + LoggerService.error( + 'Error toggling isApplicable: $e', + tag: 'RoomSpeedTestSelector', + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: AppColors.error, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isUpdating = false; + }); + } + } + } + @override Widget build(BuildContext context) { if (_isLoading) { @@ -417,39 +494,41 @@ class _RoomSpeedTestSelectorState extends ConsumerState { const SizedBox(height: 16), _buildResultDetails(currentResult, selectedConfig), - const SizedBox(height: 16), - - // Run test button - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - int? testedViaAccessPointId = - currentResult.testedViaAccessPointId; - if (testedViaAccessPointId == null && - widget.apIds.isNotEmpty) { - testedViaAccessPointId = widget.apIds.first; - } - - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => SpeedTestPopup( - cachedTest: selectedConfig, - onCompleted: () { - _loadSpeedTests(); - }, - ), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), + // Run test button (hidden when result is not applicable) + if (currentResult.isApplicable) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + int? testedViaAccessPointId = + currentResult.testedViaAccessPointId; + if (testedViaAccessPointId == null && + widget.apIds.isNotEmpty) { + testedViaAccessPointId = widget.apIds.first; + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => SpeedTestPopup( + cachedTest: selectedConfig, + existingResult: currentResult, + onCompleted: () { + _loadSpeedTests(); + }, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Run Test'), ), - child: const Text('Run Test'), ), - ), + ], ], ), ), @@ -831,6 +910,49 @@ class _RoomSpeedTestSelectorState extends ConsumerState { ), ), ], + + // Toggle applicable button + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + onPressed: _isUpdating ? null : () => _toggleApplicable(result), + icon: _isUpdating + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textSecondary, + ), + ) + : Icon( + result.isApplicable + ? Icons.block + : Icons.check_circle_outline, + size: 18, + color: AppColors.textSecondary, + ), + label: Text( + result.isApplicable + ? 'Mark as Not Applicable' + : 'Mark as Applicable', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + side: BorderSide(color: AppColors.gray600), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + ), + ), + ], + ), ], ), ); diff --git a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart index 779a6c5..2a7dd30 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; @@ -16,11 +17,15 @@ class SpeedTestPopup extends ConsumerStatefulWidget { /// Callback when result should be submitted (auto-called when test passes) final void Function(SpeedTestResult result)? onResultSubmitted; + /// Existing result to update (instead of creating a new one) + final SpeedTestResult? existingResult; + const SpeedTestPopup({ super.key, this.cachedTest, this.onCompleted, this.onResultSubmitted, + this.existingResult, }); @override @@ -32,6 +37,9 @@ class _SpeedTestPopupState extends ConsumerState late AnimationController _pulseController; late Animation _pulseAnimation; bool _resultSubmitted = false; + bool _isSubmitting = false; + bool? _submissionSuccess; + String? _submissionError; @override void initState() { @@ -85,7 +93,7 @@ class _SpeedTestPopupState extends ConsumerState } } - void _handleTestCompleted() { + Future _handleTestCompleted() async { if (_resultSubmitted) return; final testState = ref.read(speedTestRunNotifierProvider); @@ -113,13 +121,124 @@ class _SpeedTestPopupState extends ConsumerState ); LoggerService.info( - 'SpeedTestPopup: Auto-submitting result - passed=${testState.testPassed}, ' + 'SpeedTestPopup: Auto-submitting result via callback - passed=${testState.testPassed}, ' 'download=${testState.downloadSpeed}, upload=${testState.uploadSpeed}', tag: 'SpeedTestPopup', ); widget.onResultSubmitted?.call(submitResult); _resultSubmitted = true; + } else if (_effectiveConfig != null) { + // No callback provided, submit internally via provider + await _submitResultInternally(); + } + } + + Future _submitResultInternally() async { + if (_isSubmitting || _resultSubmitted) return; + + setState(() { + _isSubmitting = true; + _submissionSuccess = null; + _submissionError = null; + }); + + try { + final existingId = widget.existingResult?.id; + final isUpdate = existingId != null; + + LoggerService.info( + 'SpeedTestPopup: existingResult=${widget.existingResult != null}, ' + 'existingId=$existingId, isUpdate=$isUpdate, ' + 'configId=${_effectiveConfig?.id}', + tag: 'SpeedTestPopup', + ); + + SpeedTestResult? resultFromServer; + + if (isUpdate) { + // Update existing result with new test data + final testState = ref.read(speedTestRunNotifierProvider); + final completedResult = testState.completedResult; + + final updatedResult = widget.existingResult!.copyWith( + downloadMbps: testState.downloadSpeed, + uploadMbps: testState.uploadSpeed, + rtt: testState.latency, + passed: testState.testPassed ?? false, + initiatedAt: completedResult?.initiatedAt ?? DateTime.now(), + completedAt: DateTime.now(), + testType: 'iperf3', + source: testState.localIpAddress, + destination: testState.serverHost, + port: testState.serverPort, + iperfProtocol: testState.useUdp ? 'udp' : 'tcp', + ); + + LoggerService.info( + 'SpeedTestPopup: Updating result id=$existingId with ' + 'download=${testState.downloadSpeed}, upload=${testState.uploadSpeed}, ' + 'source=${testState.localIpAddress}, destination=${testState.serverHost}', + tag: 'SpeedTestPopup', + ); + + final notifier = ref.read( + speedTestResultsNotifierProvider( + speedTestId: updatedResult.speedTestId, + ).notifier, + ); + resultFromServer = await notifier.updateResult(updatedResult); + } else { + // Create new result + LoggerService.info( + 'SpeedTestPopup: Creating new result (no existing result to update)', + tag: 'SpeedTestPopup', + ); + resultFromServer = await ref + .read(speedTestRunNotifierProvider.notifier) + .submitResult(); + } + + final success = resultFromServer != null; + + // Update the WebSocket cache so it appears in the list + if (resultFromServer != null) { + ref + .read(webSocketCacheIntegrationProvider) + .updateSpeedTestResultInCache(resultFromServer); + LoggerService.info( + 'SpeedTestPopup: ${isUpdate ? "Updated" : "Added"} result ${resultFromServer.id} in cache', + tag: 'SpeedTestPopup', + ); + } + + if (mounted) { + setState(() { + _isSubmitting = false; + _submissionSuccess = success; + _resultSubmitted = true; + if (!success) { + _submissionError = 'Failed to ${isUpdate ? "update" : "submit"} result'; + } + }); + } + + LoggerService.info( + 'SpeedTestPopup: ${isUpdate ? "Update" : "Submission"} ${success ? "succeeded" : "failed"}', + tag: 'SpeedTestPopup', + ); + } catch (e) { + LoggerService.error( + 'SpeedTestPopup: Error submitting result: $e', + tag: 'SpeedTestPopup', + ); + if (mounted) { + setState(() { + _isSubmitting = false; + _submissionSuccess = false; + _submissionError = e.toString(); + }); + } } } @@ -721,6 +840,111 @@ class _SpeedTestPopupState extends ConsumerState ), ], + // Submission status feedback + if (status == SpeedTestStatus.completed && _effectiveConfig != null) ...[ + const SizedBox(height: 16), + if (_isSubmitting) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Submitting result...', + style: TextStyle( + color: AppColors.primary, + fontSize: 13, + ), + ), + ], + ), + ) + else if (_submissionSuccess == true) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.success.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.check_circle, + color: AppColors.success, + size: 20, + ), + const SizedBox(width: 12), + Text( + 'Result submitted successfully', + style: TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + else if (_submissionSuccess == false) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: AppColors.error, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _submissionError ?? 'Failed to submit result', + style: TextStyle( + color: AppColors.error, + fontSize: 13, + ), + ), + ), + TextButton( + onPressed: _submitResultInternally, + child: Text( + 'Retry', + style: TextStyle( + color: AppColors.error, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ], + // Action buttons const SizedBox(height: 16), @@ -796,7 +1020,12 @@ class _SpeedTestPopupState extends ConsumerState Expanded( child: ElevatedButton.icon( onPressed: () { - _resultSubmitted = false; + setState(() { + _resultSubmitted = false; + _isSubmitting = false; + _submissionSuccess = null; + _submissionError = null; + }); ref .read(speedTestRunNotifierProvider.notifier) .reset(); @@ -838,7 +1067,12 @@ class _SpeedTestPopupState extends ConsumerState Expanded( child: ElevatedButton.icon( onPressed: () { - _resultSubmitted = false; + setState(() { + _resultSubmitted = false; + _isSubmitting = false; + _submissionSuccess = null; + _submissionError = null; + }); ref .read(speedTestRunNotifierProvider.notifier) .reset(); From 100ca3009f710468e487f3495e8bd851beb94890 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Wed, 21 Jan 2026 15:24:47 -0800 Subject: [PATCH 11/24] Uplink added --- lib/core/config/environment.dart | 11 - lib/core/providers/websocket_providers.dart | 10 + lib/core/services/ap_uplink_service.dart | 154 ++++++ .../services/websocket_cache_integration.dart | 521 ++++++++++++++++++ .../services/websocket_data_sync_service.dart | 16 + .../unified_list/unified_list_item.dart | 6 +- .../data/models/device_model_sealed.dart | 37 +- .../models/device_model_sealed.freezed.dart | 61 +- .../data/models/device_model_sealed.g.dart | 2 + .../presentation/screens/devices_screen.dart | 40 +- .../widgets/device_speed_test_section.dart | 3 + .../health_notices_remote_data_source.dart | 38 +- .../providers/health_notices_provider.dart | 28 +- .../providers/health_notices_provider.g.dart | 8 +- .../screens/room_detail_screen.dart | 39 +- .../widgets/room_speed_test_selector.dart | 1 + .../widgets/speed_test_popup.dart | 83 ++- 17 files changed, 941 insertions(+), 117 deletions(-) create mode 100644 lib/core/services/ap_uplink_service.dart diff --git a/lib/core/config/environment.dart b/lib/core/config/environment.dart index 6dd0dc0..c0062e7 100644 --- a/lib/core/config/environment.dart +++ b/lib/core/config/environment.dart @@ -1,5 +1,3 @@ -import 'package:flutter/foundation.dart'; - /// Environment configuration for different build flavors enum Environment { development, staging, production } @@ -11,15 +9,6 @@ class EnvironmentConfig { static void setEnvironment(Environment env) { _environment = env; - // Only log in debug mode to avoid memory issues - if (kDebugMode) { - debugPrint( - 'EnvironmentConfig: Environment set, isDevelopment=$isDevelopment, ' - 'isStaging=$isStaging, isProduction=$isProduction', - ); - debugPrint('EnvironmentConfig: WebSocket URL will be: $websocketBaseUrl'); - debugPrint('EnvironmentConfig: useSyntheticData=$useSyntheticData'); - } } static Environment get environment => _environment; diff --git a/lib/core/providers/websocket_providers.dart b/lib/core/providers/websocket_providers.dart index b3b39dc..8790888 100644 --- a/lib/core/providers/websocket_providers.dart +++ b/lib/core/providers/websocket_providers.dart @@ -5,6 +5,7 @@ import 'package:rgnets_fdk/core/config/environment.dart'; import 'package:rgnets_fdk/core/config/logger_config.dart'; import 'package:rgnets_fdk/core/providers/core_providers.dart'; import 'package:rgnets_fdk/core/providers/repository_providers.dart'; +import 'package:rgnets_fdk/core/services/ap_uplink_service.dart'; import 'package:rgnets_fdk/core/services/cache_manager.dart'; import 'package:rgnets_fdk/core/services/websocket_cache_integration.dart'; import 'package:rgnets_fdk/core/services/websocket_data_sync_service.dart'; @@ -162,6 +163,15 @@ final webSocketCacheIntegrationProvider = Provider(( return integration; }); +/// Provides AP uplink info via cached lookups (fetching as needed). +final apUplinkInfoProvider = FutureProvider.family(( + ref, + apId, +) { + final cacheIntegration = ref.watch(webSocketCacheIntegrationProvider); + return cacheIntegration.getAPUplinkInfo(apId); +}); + /// Emits the last device-cache update time for WebSocket snapshots/updates. final webSocketDeviceLastUpdateProvider = StreamProvider((ref) { final integration = ref.watch(webSocketCacheIntegrationProvider); diff --git a/lib/core/services/ap_uplink_service.dart b/lib/core/services/ap_uplink_service.dart new file mode 100644 index 0000000..4f61d29 --- /dev/null +++ b/lib/core/services/ap_uplink_service.dart @@ -0,0 +1,154 @@ +import 'package:logger/logger.dart'; +import 'package:rgnets_fdk/core/services/websocket_service.dart'; + +class APUplinkInfo { + const APUplinkInfo({ + required this.apId, + this.linkSpeed, + this.speedInBps, + this.portName, + this.portNumber, + this.rawPortData, + }); + + final int apId; + final int? linkSpeed; + final int? speedInBps; + final String? portName; + final int? portNumber; + final Map? rawPortData; +} + +class APUplinkService { + APUplinkService({ + required WebSocketService webSocketService, + Logger? logger, + Map? cache, + }) : _webSocketService = webSocketService, + _logger = logger ?? Logger(), + _cache = cache ?? {}; + + final WebSocketService _webSocketService; + final Logger _logger; + final Map _cache; + final Map> _inFlight = {}; + + Map get cache => _cache; + + APUplinkInfo? getCachedUplink(int apId) { + return _cache[apId]; + } + + Future getAPUplinkPortDetail(int apId) { + final cached = _cache[apId]; + if (cached != null) { + return Future.value(cached); + } + + final inflight = _inFlight[apId]; + if (inflight != null) { + return inflight; + } + + final request = fetchAPUplinkDetail(apId); + _inFlight[apId] = request; + return request.whenComplete(() => _inFlight.remove(apId)); + } + + Future fetchAPUplinkDetail(int apId) async { + if (!_webSocketService.isConnected) { + _logger.w( + 'APUplinkService: WebSocket disconnected, cannot fetch uplink for AP $apId', + ); + return null; + } + + try { + final apResponse = await _webSocketService.requestActionCable( + action: 'resource_action', + resourceType: 'access_points', + additionalData: {'crud_action': 'show', 'id': apId}, + timeout: const Duration(seconds: 15), + ); + + final infrastructureLinkId = _parseInt( + apResponse.payload['data']?['infrastructure_link_id'], + ); + if (infrastructureLinkId == null) { + _logger.i( + 'APUplinkService: No infrastructure_link_id found for AP $apId', + ); + return null; + } + + final linkResponse = await _webSocketService.requestActionCable( + action: 'resource_action', + resourceType: 'infrastructure_links', + additionalData: {'crud_action': 'show', 'id': infrastructureLinkId}, + timeout: const Duration(seconds: 15), + ); + + final switchPorts = linkResponse.payload['data']?['switch_ports']; + if (switchPorts is! List || switchPorts.isEmpty) { + _logger.i( + 'APUplinkService: No switch_ports found for infrastructure link $infrastructureLinkId', + ); + return null; + } + + final firstPort = switchPorts.first; + final portId = firstPort is Map + ? _parseInt(firstPort['id']) + : _parseInt(firstPort); + if (portId == null) { + _logger.w( + 'APUplinkService: Could not determine switch port id for AP $apId', + ); + return null; + } + + final portResponse = await _webSocketService.requestActionCable( + action: 'resource_action', + resourceType: 'switch_ports', + additionalData: {'crud_action': 'show', 'id': portId}, + timeout: const Duration(seconds: 15), + ); + + final portData = portResponse.payload['data']; + if (portData is! Map) { + _logger.w( + 'APUplinkService: Invalid switch_port payload for port $portId', + ); + return null; + } + + final info = APUplinkInfo( + apId: apId, + linkSpeed: _parseInt(portData['link_speed']), + speedInBps: _parseInt(portData['speed_in_bps']), + portName: portData['name']?.toString(), + portNumber: _parseInt(portData['port']), + rawPortData: portData, + ); + + _cache[apId] = info; + return info; + } catch (e) { + _logger.e('APUplinkService: Failed to fetch uplink for AP $apId: $e'); + return null; + } + } + + void clearCache() { + _cache.clear(); + _inFlight.clear(); + } + + int? _parseInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value); + return int.tryParse(value.toString()); + } +} diff --git a/lib/core/services/websocket_cache_integration.dart b/lib/core/services/websocket_cache_integration.dart index a127dec..db00869 100644 --- a/lib/core/services/websocket_cache_integration.dart +++ b/lib/core/services/websocket_cache_integration.dart @@ -4,8 +4,12 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; +<<<<<<< HEAD import 'package:rgnets_fdk/core/constants/device_field_sets.dart'; import 'package:rgnets_fdk/core/services/device_update_event_bus.dart'; +======= +import 'package:rgnets_fdk/core/services/ap_uplink_service.dart'; +>>>>>>> 3bdf0aa (Uplink added) import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/core/services/websocket_service.dart'; import 'package:rgnets_fdk/core/utils/image_url_normalizer.dart'; @@ -30,13 +34,29 @@ class WebSocketCacheIntegration { DeviceUpdateEventBus? deviceUpdateEventBus, }) : _webSocketService = webSocketService, _imageBaseUrl = imageBaseUrl, +<<<<<<< HEAD _logger = logger ?? Logger(), _deviceUpdateEventBus = deviceUpdateEventBus; +======= + _logger = logger ?? Logger() { + _apUplinkCache = {}; + _apUplinkService = APUplinkService( + webSocketService: _webSocketService, + logger: _logger, + cache: _apUplinkCache, + ); + } +>>>>>>> 3bdf0aa (Uplink added) final WebSocketService _webSocketService; final String? _imageBaseUrl; final Logger _logger; +<<<<<<< HEAD final DeviceUpdateEventBus? _deviceUpdateEventBus; +======= + late final Map _apUplinkCache; + late final APUplinkService _apUplinkService; +>>>>>>> 3bdf0aa (Uplink added) /// Device resource types to subscribe to. static const List _deviceResourceTypes = [ @@ -109,6 +129,470 @@ class WebSocketCacheIntegration { /// Check if we have cached device data. bool get hasDeviceCache => _deviceCache.values.any((list) => list.isNotEmpty); +<<<<<<< HEAD +======= + /// Check if we have cached speed test config data. + bool get hasSpeedTestConfigCache => _speedTestConfigCache.isNotEmpty; + + /// Check if we have cached speed test result data. + bool get hasSpeedTestResultCache => _speedTestResultCache.isNotEmpty; + + /// Get cached AP uplink info for a specific access point. + APUplinkInfo? getCachedAPUplink(int apId) { + return _apUplinkCache[apId]; + } + + /// Get AP uplink info, fetching and caching if needed. + Future getAPUplinkInfo(int apId) { + return _apUplinkService.getAPUplinkPortDetail(apId); + } + + /// Fetch AP uplink detail (3-step lookup) and update cache. + Future fetchAPUplinkDetail(int apId) { + return _apUplinkService.fetchAPUplinkDetail(apId); + } + + /// Register a callback for speed test config data updates. + void onSpeedTestConfigData(void Function(List) callback) { + _speedTestConfigCallbacks.add(callback); + } + + /// Register a callback for speed test result data updates. + void onSpeedTestResultData(void Function(List) callback) { + _speedTestResultCallbacks.add(callback); + } + + /// Get cached speed test configs as domain entities. + List getCachedSpeedTestConfigs() { + return _speedTestConfigCache.map((json) { + try { + return SpeedTestConfig.fromJson(json); + } catch (e) { + _logger.w('Failed to parse speed test config: $e'); + return null; + } + }).whereType().toList(); + } + + /// Get cached speed test results as domain entities. + List getCachedSpeedTestResults() { + var parseFailures = 0; + final results = _speedTestResultCache.map((json) { + try { + return SpeedTestResult.fromJsonWithValidation(json); + } catch (e) { + parseFailures++; + LoggerService.warning( + 'Failed to parse speed test result id=${json['id']}: $e', + tag: 'SpeedTestCache', + ); + return null; + } + }).whereType().toList(); + + if (parseFailures > 0) { + LoggerService.warning( + 'Parse failures: $parseFailures out of ${_speedTestResultCache.length} raw results', + tag: 'SpeedTestCache', + ); + } + + return results; + } + + /// Get cached speed test results for a specific access point. + List getSpeedTestResultsForAccessPointId(int accessPointId) { + // Debug: Log raw cache size + LoggerService.info( + 'Raw cache has ${_speedTestResultCache.length} items, looking for accessPointId=$accessPointId', + tag: 'SpeedTestCache', + ); + + // Log first few raw items to see tested_via_access_point_id values + for (var i = 0; i < _speedTestResultCache.length && i < 5; i++) { + final raw = _speedTestResultCache[i]; + // Check both the direct ID field and the nested association object + final directId = raw['tested_via_access_point_id']; + final nestedObj = raw['tested_via_access_point']; + final nestedId = nestedObj is Map ? nestedObj['id'] : null; + LoggerService.info( + 'RawCache[$i]: id=${raw['id']}, direct_id=$directId, nested_id=$nestedId', + tag: 'SpeedTestCache', + ); + } + + final results = getCachedSpeedTestResults(); + if (results.isEmpty) { + LoggerService.info( + 'Parsed results is empty', + tag: 'SpeedTestCache', + ); + return []; + } + + LoggerService.info( + 'Parsed ${results.length} results from cache', + tag: 'SpeedTestCache', + ); + + // Log parsed results with their testedViaAccessPointId values + final apIdSet = results + .map((r) => r.testedViaAccessPointId) + .where((id) => id != null) + .toSet(); + LoggerService.info( + 'Unique testedViaAccessPointId values after parsing: $apIdSet', + tag: 'SpeedTestCache', + ); + + final filtered = results + .where((result) => result.testedViaAccessPointId == accessPointId) + .toList() + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + LoggerService.info( + 'Found ${filtered.length} results for accessPointId=$accessPointId', + tag: 'SpeedTestCache', + ); + + return filtered; + } + + /// Get the adhoc speed test config (first config with "adhoc" in name, or first config if none match). + SpeedTestConfig? getAdhocSpeedTestConfig() { + final configs = getCachedSpeedTestConfigs(); + if (configs.isEmpty) return null; + + // Try to find a config with "adhoc" in the name + final adhocConfig = configs.where( + (c) => c.name?.toLowerCase().contains('adhoc') ?? false, + ).firstOrNull; + + // Return adhoc config if found, otherwise return first config + return adhocConfig ?? configs.first; + } + + /// Get a speed test config by its ID. + SpeedTestConfig? getSpeedTestConfigById(int? configId) { + if (configId == null) { + return null; + } + + final configs = getCachedSpeedTestConfigs(); + return configs.where((c) => c.id == configId).firstOrNull; + } + + /// Get cached speed test results for a specific device. + /// Filters results by tested_via_access_point_id (AP) or + /// tested_via_media_converter_id (ONT). + /// + /// [deviceId] can be prefixed ("ap_123") or unprefixed ("123"). + /// [deviceType] optional - if provided, used to determine which field to match. + /// Should be "access_point" or "ont" (matches DeviceTypes constants). + List getSpeedTestResultsForDevice( + String deviceId, { + String? deviceType, + }) { + final results = getCachedSpeedTestResults(); + if (results.isEmpty) return []; + + // Try to extract device type and numeric ID from prefixed deviceId (e.g., "ap_123") + String? extractedType; + int? numericId; + + final parts = deviceId.split('_'); + if (parts.length >= 2) { + // Prefixed format: "ap_123" or "ont_456" + extractedType = parts[0].toLowerCase(); + numericId = int.tryParse(parts.sublist(1).join('_')); + } else { + // Unprefixed format: just "123" + numericId = int.tryParse(deviceId); + } + + if (numericId == null) return []; + + // Determine the effective device type + // Priority: extracted from ID > passed deviceType parameter + String? effectiveType = extractedType; + if (effectiveType == null && deviceType != null) { + // Map DeviceTypes constants to our internal types + if (deviceType == 'access_point') { + effectiveType = 'ap'; + } else if (deviceType == 'ont') { + effectiveType = 'ont'; + } + } + + if (effectiveType == null) { + _logger.w( + 'getSpeedTestResultsForDevice: Cannot determine device type for $deviceId', + ); + return []; + } + + _logger.i( + 'getSpeedTestResultsForDevice: Searching for $effectiveType device with numericId=$numericId ' + 'in ${results.length} cached results (raw cache: ${_speedTestResultCache.length})', + ); + + // Log first few raw results to see what's actually in the cache + for (var i = 0; i < _speedTestResultCache.length && i < 3; i++) { + final raw = _speedTestResultCache[i]; + _logger.i( + 'RawResult[$i]: id=${raw['id']}, access_point_id=${raw['access_point_id']}, ' + 'tested_via_access_point_id=${raw['tested_via_access_point_id']}, ' + 'tested_via_media_converter_id=${raw['tested_via_media_converter_id']}', + ); + } + + // Log first few parsed results for debugging + for (var i = 0; i < results.length && i < 3; i++) { + final r = results[i]; + _logger.i( + 'ParsedResult[$i]: id=${r.id}, accessPointId=${r.accessPointId}, ' + 'testedViaAccessPointId=${r.testedViaAccessPointId}, ' + 'testedViaMediaConverterId=${r.testedViaMediaConverterId}', + ); + } + + // Filter results based on device type + return results.where((result) { + if (effectiveType == 'ap') { + // For access points, check tested_via_access_point_id only + return result.testedViaAccessPointId == numericId; + } else if (effectiveType == 'ont') { + // For ONTs (media converters), check tested_via_media_converter_id + return result.testedViaMediaConverterId == numericId; + } + return false; + }).toList() + // Sort by timestamp descending (most recent first) + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + } + + /// Get the most recent speed test result for a specific device. + SpeedTestResult? getLatestSpeedTestResultForDevice( + String deviceId, { + String? deviceType, + }) { + final results = getSpeedTestResultsForDevice(deviceId, deviceType: deviceType); + return results.isNotEmpty ? results.first : null; + } + + /// Create an adhoc speed test result and send via WebSocket. + /// Returns true if successful, false otherwise. + /// + /// If [deviceId] is provided (format: "ap_123" or "ont_456"), the appropriate + /// device field (access_point_id or tested_via_media_converter_id) will be set. + Future createAdhocSpeedTestResult({ + required double downloadSpeed, + required double uploadSpeed, + required double latency, + String? source, + String? destination, + int? port, + String? protocol, + bool? passed, + String? deviceId, + }) async { + // Find the adhoc config to get its ID + final adhocConfig = getAdhocSpeedTestConfig(); + if (adhocConfig?.id == null) { + _logger.w('WebSocketCacheIntegration: No adhoc config found for result submission'); + return false; + } + + if (!_webSocketService.isConnected) { + _logger.w('WebSocketCacheIntegration: Cannot submit result - WebSocket not connected'); + return false; + } + + try { + _logger.i( + 'WebSocketCacheIntegration: Submitting adhoc speed test result - ' + 'configId=${adhocConfig!.id}, download=$downloadSpeed, upload=$uploadSpeed, deviceId=$deviceId', + ); + + // Parse deviceId to extract type and numeric ID for device association + int? accessPointId; + int? testedViaMediaConverterId; + if (deviceId != null) { + final parts = deviceId.split('_'); + if (parts.length >= 2) { + final deviceType = parts[0].toLowerCase(); + final numericId = int.tryParse(parts.sublist(1).join('_')); + if (numericId != null) { + if (deviceType == 'ap') { + accessPointId = numericId; + } else if (deviceType == 'ont') { + testedViaMediaConverterId = numericId; + } + } + } + } + + // Send CREATE request via ActionCable WebSocket + final response = await _webSocketService.requestActionCable( + action: 'create_resource', + resourceType: _speedTestResultResourceType, + additionalData: { + 'params': { + 'speed_test_id': adhocConfig.id, + 'download_mbps': downloadSpeed, + 'upload_mbps': uploadSpeed, + 'rtt': latency, + 'completed_at': DateTime.now().toIso8601String(), + 'test_type': 'iperf3', + if (source != null) 'source': source, + if (destination != null) 'destination': destination, + if (port != null) 'port': port, + if (protocol != null) 'iperf_protocol': protocol, + if (passed != null) 'passed': passed, + if (accessPointId != null) 'access_point_id': accessPointId, + if (testedViaMediaConverterId != null) 'tested_via_media_converter_id': testedViaMediaConverterId, + }, + }, + timeout: const Duration(seconds: 15), + ); + + final hasError = response.payload['error'] != null; + if (hasError) { + _logger.e( + 'WebSocketCacheIntegration: Failed to submit result - ${response.payload['error']}', + ); + return false; + } + + _logger.i('WebSocketCacheIntegration: Adhoc speed test result submitted successfully'); + return true; + } catch (e) { + _logger.e('WebSocketCacheIntegration: Error submitting adhoc result: $e'); + return false; + } + } + + /// Update an existing speed test result for a specific device. + /// Finds the existing result by device ID (AP or ONT) and updates it. + /// Returns true if successful, false otherwise. + /// + /// [deviceId] format: "ap_123" or "ont_456" + Future updateDeviceSpeedTestResult({ + required String deviceId, + required double downloadSpeed, + required double uploadSpeed, + required double latency, + String? source, + String? destination, + int? port, + String? protocol, + bool? passed, + }) async { + if (!_webSocketService.isConnected) { + _logger.w('WebSocketCacheIntegration: Cannot update result - WebSocket not connected'); + return false; + } + + // Find existing result for this device + final existingResult = getLatestSpeedTestResultForDevice(deviceId); + if (existingResult == null || existingResult.id == null) { + _logger.w( + 'WebSocketCacheIntegration: No existing speed test result found for device $deviceId', + ); + return false; + } + + try { + _logger.i( + 'WebSocketCacheIntegration: Updating speed test result ${existingResult.id} for device $deviceId - ' + 'download=$downloadSpeed, upload=$uploadSpeed', + ); + + // Send UPDATE request via ActionCable WebSocket + final response = await _webSocketService.requestActionCable( + action: 'update_resource', + resourceType: _speedTestResultResourceType, + additionalData: { + 'id': existingResult.id, + 'params': { + 'download_mbps': downloadSpeed, + 'upload_mbps': uploadSpeed, + 'rtt': latency, + 'completed_at': DateTime.now().toIso8601String(), + if (source != null) 'source': source, + if (destination != null) 'destination': destination, + if (port != null) 'port': port, + if (protocol != null) 'iperf_protocol': protocol, + if (passed != null) 'passed': passed, + }, + }, + timeout: const Duration(seconds: 15), + ); + + final hasError = response.payload['error'] != null; + if (hasError) { + _logger.e( + 'WebSocketCacheIntegration: Failed to update result - ${response.payload['error']}', + ); + return false; + } + + _logger.i( + 'WebSocketCacheIntegration: Speed test result ${existingResult.id} updated successfully', + ); + return true; + } catch (e) { + _logger.e('WebSocketCacheIntegration: Error updating device result: $e'); + return false; + } + } + + /// Get cached speed test configs as raw JSON maps. + List> getCachedSpeedTestConfigsRaw() { + return List.unmodifiable(_speedTestConfigCache); + } + + /// Get cached speed test results as raw JSON maps. + List> getCachedSpeedTestResultsRaw() { + return List.unmodifiable(_speedTestResultCache); + } + + /// Update a single speed test result in the cache. + /// This is useful after updating a result via the API to keep the cache in sync. + /// Merges new data with existing cache entry to preserve fields the server may not return. + void updateSpeedTestResultInCache(SpeedTestResult result) { + if (result.id == null) { + LoggerService.warning( + 'Cannot update speed test result in cache without id', + tag: 'SpeedTestCache', + ); + return; + } + + final newJson = result.toJson(); + final index = _speedTestResultCache.indexWhere((item) => item['id'] == result.id); + + if (index >= 0) { + // Merge new data with existing cache entry to preserve fields like pms_room_id + // that the server may not return in the update response + final existingJson = Map.from(_speedTestResultCache[index]) + ..addAll(newJson); + _speedTestResultCache[index] = existingJson; + LoggerService.info( + 'Updated speed test result ${result.id} in cache (merged with existing)', + tag: 'SpeedTestCache', + ); + } else { + _speedTestResultCache.add(newJson); + LoggerService.info( + 'Added speed test result ${result.id} to cache', + tag: 'SpeedTestCache', + ); + } + _bumpLastUpdate(); + } + +>>>>>>> 3bdf0aa (Uplink added) /// Get cached devices by resource type. List>? getCachedDevices(String resourceType) { return _deviceCache[resourceType]; @@ -193,12 +677,18 @@ class WebSocketCacheIntegration { imageSignedIds: apImageData?.signedIds, hnCounts: hnCounts, healthNotices: healthNotices, +<<<<<<< HEAD metadata: deviceMap, onboardingStatus: deviceMap['ap_onboarding_status'] != null ? OnboardingStatusPayload.fromJson( deviceMap['ap_onboarding_status'] as Map, ) : null, +======= + infrastructureLinkId: _parseOptionalInt( + deviceMap['infrastructure_link_id'], + ), +>>>>>>> 3bdf0aa (Uplink added) ); case 'media_converters': @@ -305,8 +795,25 @@ class WebSocketCacheIntegration { return null; } +<<<<<<< HEAD /// Extract images with both URLs and signed IDs. ImageExtraction? _extractImagesData(Map deviceMap) { +======= + int? _parseOptionalInt(dynamic value) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value is double) { + return value.toInt(); + } + return int.tryParse(value.toString()); + } + + List? _extractImages(Map deviceMap) { +>>>>>>> 3bdf0aa (Uplink added) final imagesValue = deviceMap['images'] ?? deviceMap['pictures']; return extractImagesWithSignedIds(imagesValue, baseUrl: _imageBaseUrl); } @@ -1082,6 +1589,14 @@ class WebSocketCacheIntegration { _deviceCache.clear(); _roomCache.clear(); +<<<<<<< HEAD +======= + // Clear speed test caches + _speedTestConfigCache.clear(); + _speedTestResultCache.clear(); + _apUplinkService.clearCache(); + +>>>>>>> 3bdf0aa (Uplink added) // Clear snapshot state for (final timer in _snapshotFlushTimers.values) { timer.cancel(); @@ -1118,6 +1633,12 @@ class WebSocketCacheIntegration { _roomDataCallbacks.clear(); _deviceCache.clear(); _roomCache.clear(); +<<<<<<< HEAD +======= + _speedTestConfigCache.clear(); + _speedTestResultCache.clear(); + _apUplinkService.clearCache(); +>>>>>>> 3bdf0aa (Uplink added) } /// Request a specific resource type snapshot manually. diff --git a/lib/core/services/websocket_data_sync_service.dart b/lib/core/services/websocket_data_sync_service.dart index 4b45572..69fc3bc 100644 --- a/lib/core/services/websocket_data_sync_service.dart +++ b/lib/core/services/websocket_data_sync_service.dart @@ -505,6 +505,9 @@ class WebSocketDataSyncService { note: data['note']?.toString(), images: _extractImages(data), metadata: data, + infrastructureLinkId: _parseOptionalInt( + data['infrastructure_link_id'], + ), connectionState: data['connection_state']?.toString(), signalStrength: data['signal_strength'] as int?, connectedClients: data['connected_clients'] as int?, @@ -680,6 +683,19 @@ class WebSocketDataSyncService { return null; } + int? _parseOptionalInt(dynamic value) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + if (value is double) { + return value.toInt(); + } + return int.tryParse(value.toString()); + } + List? _extractImages(Map deviceMap) { final imageKeys = [ 'images', diff --git a/lib/core/widgets/unified_list/unified_list_item.dart b/lib/core/widgets/unified_list/unified_list_item.dart index 5a54f67..a37bd25 100644 --- a/lib/core/widgets/unified_list/unified_list_item.dart +++ b/lib/core/widgets/unified_list/unified_list_item.dart @@ -11,6 +11,7 @@ class UnifiedListItem extends StatelessWidget { super.key, this.subtitleLines = const [], this.iconColorOverride, + this.titleColor, this.statusBadge, this.onTap, this.showChevron = false, @@ -23,6 +24,7 @@ class UnifiedListItem extends StatelessWidget { final UnifiedItemStatus status; final List subtitleLines; final Color? iconColorOverride; + final Color? titleColor; final UnifiedStatusBadge? statusBadge; final VoidCallback? onTap; final bool showChevron; @@ -66,7 +68,7 @@ class UnifiedListItem extends StatelessWidget { title, style: TextStyle( fontWeight: isUnread ? FontWeight.bold : FontWeight.w600, - color: Colors.white, + color: titleColor ?? AppColors.textPrimary, ), ), ), @@ -244,4 +246,4 @@ class UnifiedInfoLine { final IconData? icon; final Color? color; final int maxLines; -} \ No newline at end of file +} diff --git a/lib/features/devices/data/models/device_model_sealed.dart b/lib/features/devices/data/models/device_model_sealed.dart index 5a02c11..0d26bd3 100644 --- a/lib/features/devices/data/models/device_model_sealed.dart +++ b/lib/features/devices/data/models/device_model_sealed.dart @@ -17,10 +17,6 @@ part 'device_model_sealed.g.dart'; sealed class DeviceModelSealed with _$DeviceModelSealed { const DeviceModelSealed._(); - // ============================================================================ - // Device Type Constants - // ============================================================================ - /// Device type identifier for Access Points static const String typeAccessPoint = 'access_point'; @@ -70,13 +66,8 @@ sealed class DeviceModelSealed with _$DeviceModelSealed { return deviceTypeToResourceType[deviceType]; } - // ============================================================================ - // Access Point Model - // ============================================================================ - @FreezedUnionValue('access_point') const factory DeviceModelSealed.ap({ - // Common fields required String id, required String name, required String status, @@ -95,8 +86,7 @@ sealed class DeviceModelSealed with _$DeviceModelSealed { @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - - // AP-specific fields + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -108,13 +98,8 @@ sealed class DeviceModelSealed with _$DeviceModelSealed { @JsonKey(name: 'ap_onboarding_status') OnboardingStatusPayload? onboardingStatus, }) = APModel; - // ============================================================================ - // ONT (Optical Network Terminal) Model - // ============================================================================ - @FreezedUnionValue('ont') const factory DeviceModelSealed.ont({ - // Common fields required String id, required String name, required String status, @@ -133,8 +118,6 @@ sealed class DeviceModelSealed with _$DeviceModelSealed { @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - - // ONT-specific fields @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') OnboardingStatusPayload? onboardingStatus, @@ -143,13 +126,8 @@ sealed class DeviceModelSealed with _$DeviceModelSealed { String? phase, }) = ONTModel; - // ============================================================================ - // Switch Model - // ============================================================================ - @FreezedUnionValue('switch') const factory DeviceModelSealed.switchDevice({ - // Common fields required String id, required String name, required String status, @@ -168,8 +146,6 @@ sealed class DeviceModelSealed with _$DeviceModelSealed { @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - - // Switch-specific fields String? host, @JsonKey(name: 'switch_ports') List>? ports, @JsonKey(name: 'last_config_sync_at') DateTime? lastConfigSync, @@ -179,13 +155,8 @@ sealed class DeviceModelSealed with _$DeviceModelSealed { int? temperature, }) = SwitchModel; - // ============================================================================ - // WLAN Controller Model - // ============================================================================ - @FreezedUnionValue('wlan_controller') const factory DeviceModelSealed.wlan({ - // Common fields required String id, required String name, required String status, @@ -204,8 +175,6 @@ sealed class DeviceModelSealed with _$DeviceModelSealed { @JsonKey(name: 'image_signed_ids') List? imageSignedIds, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - - // WLAN-specific fields @JsonKey(name: 'controller_type') String? controllerType, @JsonKey(name: 'managed_aps') int? managedAPs, int? vlan, @@ -220,10 +189,6 @@ sealed class DeviceModelSealed with _$DeviceModelSealed { _$DeviceModelSealedFromJson(json); } -// ============================================================================ -// Extension for converting to Domain Entity -// ============================================================================ - extension DeviceModelSealedX on DeviceModelSealed { /// Converts this model to the unified [Device] domain entity Device toEntity() { diff --git a/lib/features/devices/data/models/device_model_sealed.freezed.dart b/lib/features/devices/data/models/device_model_sealed.freezed.dart index 48d360f..f7d903b 100644 --- a/lib/features/devices/data/models/device_model_sealed.freezed.dart +++ b/lib/features/devices/data/models/device_model_sealed.freezed.dart @@ -33,7 +33,6 @@ DeviceModelSealed _$DeviceModelSealedFromJson(Map json) { /// @nodoc mixin _$DeviceModelSealed { -// Common fields String get id => throw _privateConstructorUsedError; String get name => throw _privateConstructorUsedError; String get status => throw _privateConstructorUsedError; @@ -81,6 +80,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -198,6 +198,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -315,6 +316,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -626,6 +628,7 @@ abstract class _$$APModelImplCopyWith<$Res> List? images, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -671,6 +674,7 @@ class __$$APModelImplCopyWithImpl<$Res> Object? images = freezed, Object? healthNotices = freezed, Object? hnCounts = freezed, + Object? infrastructureLinkId = freezed, Object? connectionState = freezed, Object? signalStrength = freezed, Object? connectedClients = freezed, @@ -750,6 +754,10 @@ class __$$APModelImplCopyWithImpl<$Res> ? _value.hnCounts : hnCounts // ignore: cast_nullable_to_non_nullable as HealthCountsModel?, + infrastructureLinkId: freezed == infrastructureLinkId + ? _value.infrastructureLinkId + : infrastructureLinkId // ignore: cast_nullable_to_non_nullable + as int?, connectionState: freezed == connectionState ? _value.connectionState : connectionState // ignore: cast_nullable_to_non_nullable @@ -812,6 +820,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') this.hnCounts, + @JsonKey(name: 'infrastructure_link_id') this.infrastructureLinkId, @JsonKey(name: 'connection_state') this.connectionState, @JsonKey(name: 'signal_strength') this.signalStrength, @JsonKey(name: 'connected_clients') this.connectedClients, @@ -833,7 +842,6 @@ class _$APModelImpl extends APModel { factory _$APModelImpl.fromJson(Map json) => _$$APModelImplFromJson(json); -// Common fields @override final String id; @override @@ -900,7 +908,9 @@ class _$APModelImpl extends APModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; -// AP-specific fields + @override + @JsonKey(name: 'infrastructure_link_id') + final int? infrastructureLinkId; @override @JsonKey(name: 'connection_state') final String? connectionState; @@ -939,7 +949,7 @@ class _$APModelImpl extends APModel { @override String toString() { - return 'DeviceModelSealed.ap(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, healthNotices: $healthNotices, hnCounts: $hnCounts, connectionState: $connectionState, signalStrength: $signalStrength, connectedClients: $connectedClients, ssid: $ssid, channel: $channel, maxClients: $maxClients, currentUpload: $currentUpload, currentDownload: $currentDownload, onboardingStatus: $onboardingStatus)'; + return 'DeviceModelSealed.ap(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, healthNotices: $healthNotices, hnCounts: $hnCounts, infrastructureLinkId: $infrastructureLinkId, connectionState: $connectionState, signalStrength: $signalStrength, connectedClients: $connectedClients, ssid: $ssid, channel: $channel, maxClients: $maxClients, currentUpload: $currentUpload, currentDownload: $currentDownload, onboardingStatus: $onboardingStatus)'; } @override @@ -973,6 +983,8 @@ class _$APModelImpl extends APModel { .equals(other._healthNotices, _healthNotices) && (identical(other.hnCounts, hnCounts) || other.hnCounts == hnCounts) && + (identical(other.infrastructureLinkId, infrastructureLinkId) || + other.infrastructureLinkId == infrastructureLinkId) && (identical(other.connectionState, connectionState) || other.connectionState == connectionState) && (identical(other.signalStrength, signalStrength) || @@ -1012,6 +1024,7 @@ class _$APModelImpl extends APModel { const DeepCollectionEquality().hash(_images), const DeepCollectionEquality().hash(_healthNotices), hnCounts, + infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1051,6 +1064,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -1164,6 +1178,7 @@ class _$APModelImpl extends APModel { images, healthNotices, hnCounts, + infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1197,6 +1212,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -1310,6 +1326,7 @@ class _$APModelImpl extends APModel { images, healthNotices, hnCounts, + infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1343,6 +1360,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -1458,6 +1476,7 @@ class _$APModelImpl extends APModel { images, healthNotices, hnCounts, + infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1536,6 +1555,7 @@ abstract class APModel extends DeviceModelSealed { @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') final int? infrastructureLinkId, @JsonKey(name: 'connection_state') final String? connectionState, @JsonKey(name: 'signal_strength') final int? signalStrength, @JsonKey(name: 'connected_clients') final int? connectedClients, @@ -1550,7 +1570,7 @@ abstract class APModel extends DeviceModelSealed { factory APModel.fromJson(Map json) = _$APModelImpl.fromJson; - @override // Common fields + @override String get id; @override String get name; @@ -1591,7 +1611,9 @@ abstract class APModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; // AP-specific fields + HealthCountsModel? get hnCounts; + @JsonKey(name: 'infrastructure_link_id') + int? get infrastructureLinkId; @JsonKey(name: 'connection_state') String? get connectionState; @JsonKey(name: 'signal_strength') @@ -1828,7 +1850,6 @@ class _$ONTModelImpl extends ONTModel { factory _$ONTModelImpl.fromJson(Map json) => _$$ONTModelImplFromJson(json); -// Common fields @override final String id; @override @@ -1895,7 +1916,6 @@ class _$ONTModelImpl extends ONTModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; -// ONT-specific fields @override @JsonKey(name: 'is_registered') final bool? isRegistered; @@ -2044,6 +2064,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -2187,6 +2208,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -2330,6 +2352,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -2532,7 +2555,7 @@ abstract class ONTModel extends DeviceModelSealed { factory ONTModel.fromJson(Map json) = _$ONTModelImpl.fromJson; - @override // Common fields + @override String get id; @override String get name; @@ -2573,7 +2596,7 @@ abstract class ONTModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; // ONT-specific fields + HealthCountsModel? get hnCounts; @JsonKey(name: 'is_registered') bool? get isRegistered; @JsonKey(name: 'switch_port') @@ -2808,7 +2831,6 @@ class _$SwitchModelImpl extends SwitchModel { factory _$SwitchModelImpl.fromJson(Map json) => _$$SwitchModelImplFromJson(json); -// Common fields @override final String id; @override @@ -2875,7 +2897,6 @@ class _$SwitchModelImpl extends SwitchModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; -// Switch-specific fields @override final String? host; final List>? _ports; @@ -3015,6 +3036,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -3159,6 +3181,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -3303,6 +3326,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -3507,7 +3531,7 @@ abstract class SwitchModel extends DeviceModelSealed { factory SwitchModel.fromJson(Map json) = _$SwitchModelImpl.fromJson; - @override // Common fields + @override String get id; @override String get name; @@ -3548,7 +3572,7 @@ abstract class SwitchModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; // Switch-specific fields + HealthCountsModel? get hnCounts; String? get host; @JsonKey(name: 'switch_ports') List>? get ports; @@ -3790,7 +3814,6 @@ class _$WLANModelImpl extends WLANModel { factory _$WLANModelImpl.fromJson(Map json) => _$$WLANModelImplFromJson(json); -// Common fields @override final String id; @override @@ -3857,7 +3880,6 @@ class _$WLANModelImpl extends WLANModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; -// WLAN-specific fields @override @JsonKey(name: 'controller_type') final String? controllerType; @@ -3995,6 +4017,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -4140,6 +4163,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -4285,6 +4309,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -4491,7 +4516,7 @@ abstract class WLANModel extends DeviceModelSealed { factory WLANModel.fromJson(Map json) = _$WLANModelImpl.fromJson; - @override // Common fields + @override String get id; @override String get name; @@ -4532,7 +4557,7 @@ abstract class WLANModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; // WLAN-specific fields + HealthCountsModel? get hnCounts; @JsonKey(name: 'controller_type') String? get controllerType; @JsonKey(name: 'managed_aps') diff --git a/lib/features/devices/data/models/device_model_sealed.g.dart b/lib/features/devices/data/models/device_model_sealed.g.dart index 9ada501..13d2159 100644 --- a/lib/features/devices/data/models/device_model_sealed.g.dart +++ b/lib/features/devices/data/models/device_model_sealed.g.dart @@ -35,6 +35,7 @@ _$APModelImpl _$$APModelImplFromJson(Map json) => ? null : HealthCountsModel.fromJson( json['hn_counts'] as Map), + infrastructureLinkId: (json['infrastructure_link_id'] as num?)?.toInt(), connectionState: json['connection_state'] as String?, signalStrength: (json['signal_strength'] as num?)?.toInt(), connectedClients: (json['connected_clients'] as num?)?.toInt(), @@ -75,6 +76,7 @@ Map _$$APModelImplToJson(_$APModelImpl instance) { writeNotNull('health_notices', instance.healthNotices?.map((e) => e.toJson()).toList()); writeNotNull('hn_counts', instance.hnCounts?.toJson()); + writeNotNull('infrastructure_link_id', instance.infrastructureLinkId); writeNotNull('connection_state', instance.connectionState); writeNotNull('signal_strength', instance.signalStrength); writeNotNull('connected_clients', instance.connectedClients); diff --git a/lib/features/devices/presentation/screens/devices_screen.dart b/lib/features/devices/presentation/screens/devices_screen.dart index 3f8935b..1bf0573 100644 --- a/lib/features/devices/presentation/screens/devices_screen.dart +++ b/lib/features/devices/presentation/screens/devices_screen.dart @@ -2,11 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/core/services/websocket_cache_integration.dart'; +import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/utils/list_item_helpers.dart'; import 'package:rgnets_fdk/core/widgets/hud_tab_bar.dart'; import 'package:rgnets_fdk/core/widgets/unified_list/unified_list_item.dart'; import 'package:rgnets_fdk/core/widgets/widgets.dart'; +import 'package:rgnets_fdk/features/devices/domain/constants/device_types.dart'; import 'package:rgnets_fdk/features/devices/domain/entities/device.dart'; import 'package:rgnets_fdk/features/devices/presentation/providers/device_ui_state_provider.dart'; import 'package:rgnets_fdk/features/devices/presentation/providers/devices_provider.dart'; @@ -24,6 +28,23 @@ class _DevicesScreenState extends ConsumerState { final TextEditingController _searchController = TextEditingController(); final ScrollController _scrollController = ScrollController(); + int? _extractApId(String deviceId) { + final parts = deviceId.split('_'); + final rawId = parts.length >= 2 ? parts.sublist(1).join('_') : deviceId; + return int.tryParse(rawId); + } + + Color _getAPNameColor(int apId, WebSocketCacheIntegration cache) { + final uplink = cache.getCachedAPUplink(apId); + if (uplink == null) { + return AppColors.error; + } + if (uplink.speedInBps != null && uplink.speedInBps! < 2500000000) { + return AppColors.error; + } + return AppColors.textPrimary; + } + String _formatNetworkInfo(Device device) { // Safely handle null and empty values using null-aware operators final ip = (device.ipAddress?.trim().isEmpty ?? true) @@ -340,9 +361,12 @@ class _DevicesScreenState extends ConsumerState { // Device list Expanded( - child: Consumer( + child: Consumer( builder: (context, ref, child) { final uiState = ref.watch(deviceUIStateNotifierProvider); + final cacheIntegration = ref.watch( + webSocketCacheIntegrationProvider, + ); return filteredDevices.isEmpty ? EmptyState( icon: Icons.devices_other, @@ -357,8 +381,20 @@ class _DevicesScreenState extends ConsumerState { itemCount: filteredDevices.length, itemBuilder: (context, index) { final device = filteredDevices[index]; + Color? titleColor; + if (device.type == DeviceTypes.accessPoint) { + final apId = _extractApId(device.id); + if (apId != null) { + ref.watch(apUplinkInfoProvider(apId)); + titleColor = _getAPNameColor( + apId, + cacheIntegration, + ); + } + } return UnifiedListItem( title: device.name, + titleColor: titleColor, icon: ListItemHelpers.getDeviceIcon(device.type), status: ListItemHelpers.mapDeviceStatus(device.status), subtitleLines: [ @@ -387,4 +423,4 @@ class _DevicesScreenState extends ConsumerState { ), ); } -} \ No newline at end of file +} diff --git a/lib/features/devices/presentation/widgets/device_speed_test_section.dart b/lib/features/devices/presentation/widgets/device_speed_test_section.dart index fba5caa..609d742 100644 --- a/lib/features/devices/presentation/widgets/device_speed_test_section.dart +++ b/lib/features/devices/presentation/widgets/device_speed_test_section.dart @@ -144,6 +144,9 @@ class _DeviceSpeedTestSectionState builder: (BuildContext context) { return SpeedTestPopup( cachedTest: config, + apId: widget.device.type == DeviceTypes.accessPoint + ? _getNumericDeviceId() + : null, onCompleted: () { if (mounted) { // Reload results after test completion diff --git a/lib/features/issues/data/datasources/health_notices_remote_data_source.dart b/lib/features/issues/data/datasources/health_notices_remote_data_source.dart index 66692ef..08dfe77 100644 --- a/lib/features/issues/data/datasources/health_notices_remote_data_source.dart +++ b/lib/features/issues/data/datasources/health_notices_remote_data_source.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:rgnets_fdk/core/services/websocket_service.dart'; import 'package:rgnets_fdk/features/issues/data/models/health_notices_summary_model.dart'; @@ -14,6 +13,7 @@ class HealthNoticesRemoteDataSource { final WebSocketService _socketService; /// Fetches health notices summary (notices list + counts) from backend +<<<<<<< HEAD Future fetchSummary() async { if (kDebugMode) { print('HealthNoticesDataSource: fetchSummary called, isConnected=${_socketService.isConnected}'); @@ -24,14 +24,14 @@ class HealthNoticesRemoteDataSource { print('HealthNoticesDataSource: WebSocket not connected, returning empty'); } return const HealthNoticesSummaryModel(); +======= + Future fetchSummary() async { + if (!_socketService.isConnected) { + return HealthNoticesSummary.empty(); +>>>>>>> 3bdf0aa (Uplink added) } try { - if (kDebugMode) { - print('HealthNoticesDataSource: Sending request via requestActionCable...'); - } - - // Use the WebSocket service's built-in request/response correlation final response = await _socketService.requestActionCable( action: 'resource_action', resourceType: 'health_notices', @@ -39,26 +39,20 @@ class HealthNoticesRemoteDataSource { timeout: const Duration(seconds: 10), ); - if (kDebugMode) { - print('HealthNoticesDataSource: Got response type=${response.type}'); - } - - // Check for error response if (response.type == 'error') { +<<<<<<< HEAD if (kDebugMode) { print('HealthNoticesDataSource: Error response received'); } return const HealthNoticesSummaryModel(); +======= + return HealthNoticesSummary.empty(); +>>>>>>> 3bdf0aa (Uplink added) } - // For resource_response, the data is in payload['data'] - // which contains { notices: [...], counts: {...} } final responseData = response.payload['data']; - if (kDebugMode) { - print('HealthNoticesDataSource: responseData type=${responseData.runtimeType}'); - } - if (responseData is! Map) { +<<<<<<< HEAD if (kDebugMode) { print('HealthNoticesDataSource: responseData is not a Map, returning empty'); } @@ -80,6 +74,16 @@ class HealthNoticesRemoteDataSource { print('HealthNoticesDataSource: Request failed: $e'); } return const HealthNoticesSummaryModel(); +======= + return HealthNoticesSummary.empty(); + } + + return HealthNoticesSummary.fromJson(responseData); + } on TimeoutException { + return HealthNoticesSummary.empty(); + } on Exception { + return HealthNoticesSummary.empty(); +>>>>>>> 3bdf0aa (Uplink added) } } diff --git a/lib/features/issues/presentation/providers/health_notices_provider.dart b/lib/features/issues/presentation/providers/health_notices_provider.dart index e150b69..9e4719f 100644 --- a/lib/features/issues/presentation/providers/health_notices_provider.dart +++ b/lib/features/issues/presentation/providers/health_notices_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; @@ -48,42 +47,24 @@ class AggregateHealthCountsNotifier extends _$AggregateHealthCountsNotifier { // Get cached devices with health notice data from in-memory WebSocket cache final devices = cacheIntegration.getAllCachedDeviceModels(); - if (kDebugMode) { - print('AggregateHealthCountsNotifier: Found ${devices.length} cached devices'); - } - // Aggregate health counts from all devices var totalFatal = 0; var totalCritical = 0; var totalWarning = 0; var totalNotice = 0; - var devicesWithHnCounts = 0; for (final device in devices) { final counts = device.hnCounts; if (counts != null) { - devicesWithHnCounts++; totalFatal += counts.fatal; totalCritical += counts.critical; totalWarning += counts.warning; totalNotice += counts.notice; - // Log first few devices with hn_counts to debug - if (kDebugMode && devicesWithHnCounts <= 5) { - print(' DEBUG Device ${device.deviceName} (${device.deviceId}): total=${counts.total}, fatal=${counts.fatal}, critical=${counts.critical}, warning=${counts.warning}, notice=${counts.notice}'); - } } } - if (kDebugMode) { - print('AggregateHealthCountsNotifier: $devicesWithHnCounts/${devices.length} devices have hn_counts'); - } - final total = totalFatal + totalCritical + totalWarning + totalNotice; - if (kDebugMode) { - print('AggregateHealthCountsNotifier: Aggregated counts - total=$total, fatal=$totalFatal, critical=$totalCritical, warning=$totalWarning, notice=$totalNotice'); - } - return HealthCounts( total: total, fatal: totalFatal, @@ -117,11 +98,7 @@ HealthCounts aggregateHealthCounts(AggregateHealthCountsRef ref) { @Riverpod(keepAlive: true) int criticalIssueCount(CriticalIssueCountRef ref) { final notices = ref.watch(healthNoticesListProvider); - final count = notices.criticalCount; - if (kDebugMode) { - print('criticalIssueCountProvider: criticalCount=$count from ${notices.length} total notices'); - } - return count; + return notices.criticalCount; } /// Provider that extracts health notices from cached device data @@ -162,6 +139,7 @@ class HealthNoticesNotifier extends _$HealthNoticesNotifier { } } +<<<<<<< HEAD LoggerService.debug( 'HEALTH: Extracted ${notices.length} total notices from $devicesWithNotices devices with notices', tag: 'HealthNotices', @@ -171,6 +149,8 @@ class HealthNoticesNotifier extends _$HealthNoticesNotifier { print('HealthNoticesNotifier: Found ${notices.length} total notices from ${devices.length} devices'); } +======= +>>>>>>> 3bdf0aa (Uplink added) // Sort by severity (highest first), then by creation time (newest first) notices.sort((a, b) { final severityCompare = b.severity.weight.compareTo(a.severity.weight); diff --git a/lib/features/issues/presentation/providers/health_notices_provider.g.dart b/lib/features/issues/presentation/providers/health_notices_provider.g.dart index 61d4092..e4aeff5 100644 --- a/lib/features/issues/presentation/providers/health_notices_provider.g.dart +++ b/lib/features/issues/presentation/providers/health_notices_provider.g.dart @@ -26,7 +26,7 @@ final aggregateHealthCountsProvider = Provider.internal( typedef AggregateHealthCountsRef = ProviderRef; String _$criticalIssueCountHash() => - r'6fe84d5eca813eee492c1c44bd6d43518311efc2'; + r'4932300bb1c0c4ebff301cd6f4dd3707bfb042cb'; /// Provider that returns the count of critical issues (fatal + critical) /// @@ -79,7 +79,7 @@ final filteredHealthNoticesProvider = Provider>.internal( typedef FilteredHealthNoticesRef = ProviderRef>; String _$aggregateHealthCountsNotifierHash() => - r'cbb9e11f850a5bbf4c3a520a4427550d1cf65b2b'; + r'846525b9a40c26861f9caa3e7f9d48e0a0f71f9c'; /// Provider that aggregates health counts from cached device data /// This uses device data that's already received via WebSocket @@ -99,7 +99,11 @@ final aggregateHealthCountsNotifierProvider = typedef _$AggregateHealthCountsNotifier = AsyncNotifier; String _$healthNoticesNotifierHash() => +<<<<<<< HEAD r'249e101081f539d2eceef8523c67a68998788c67'; +======= + r'ff0e1e399bb3a0c928b3639131f1b83dc6d53a5e'; +>>>>>>> 3bdf0aa (Uplink added) /// Provider that extracts health notices from cached device data /// diff --git a/lib/features/rooms/presentation/screens/room_detail_screen.dart b/lib/features/rooms/presentation/screens/room_detail_screen.dart index 8183ace..c33c71c 100644 --- a/lib/features/rooms/presentation/screens/room_detail_screen.dart +++ b/lib/features/rooms/presentation/screens/room_detail_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; +import 'package:rgnets_fdk/core/services/websocket_cache_integration.dart'; +import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/widgets/widgets.dart'; import 'package:rgnets_fdk/features/devices/domain/constants/device_types.dart'; import 'package:rgnets_fdk/features/devices/presentation/providers/devices_provider.dart'; @@ -428,6 +431,7 @@ class _DevicesTab extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { // Use the new RoomDeviceNotifier for proper MVVM architecture final roomDeviceState = ref.watch(roomDeviceNotifierProvider(roomVm.id)); + final cacheIntegration = ref.watch(webSocketCacheIntegrationProvider); if (roomDeviceState.isLoading) { return const Center(child: CircularProgressIndicator()); @@ -537,6 +541,14 @@ class _DevicesTab extends ConsumerWidget { itemCount: filteredDevices.length, itemBuilder: (context, index) { final device = filteredDevices[index]; + Color? titleColor; + if (device.type == DeviceTypes.accessPoint) { + final apId = _extractApId(device.id); + if (apId != null) { + ref.watch(apUplinkInfoProvider(apId)); + titleColor = _getAPNameColor(apId, cacheIntegration); + } + } return _DeviceListItem( device: { 'id': device.id, @@ -545,6 +557,7 @@ class _DevicesTab extends ConsumerWidget { 'status': device.status, 'ipAddress': device.ipAddress, }, + titleColor: titleColor, onTap: () { // Navigate to device detail context.push('/devices/${device.id}'); @@ -673,6 +686,23 @@ class _AnalyticsTab extends StatelessWidget { } } +int? _extractApId(String deviceId) { + final parts = deviceId.split('_'); + final rawId = parts.length >= 2 ? parts.sublist(1).join('_') : deviceId; + return int.tryParse(rawId); +} + +Color _getAPNameColor(int apId, WebSocketCacheIntegration cache) { + final uplink = cache.getCachedAPUplink(apId); + if (uplink == null) { + return AppColors.error; + } + if (uplink.speedInBps != null && uplink.speedInBps! < 2500000000) { + return AppColors.error; + } + return AppColors.textPrimary; +} + class _DeviceTypeChip extends StatelessWidget { const _DeviceTypeChip({ @@ -713,9 +743,11 @@ class _DeviceListItem extends StatelessWidget { const _DeviceListItem({ required this.device, + this.titleColor, this.onTap, }); final Map device; + final Color? titleColor; final VoidCallback? onTap; @override @@ -747,7 +779,10 @@ class _DeviceListItem extends StatelessWidget { ), title: Text( device['name'] as String, - style: const TextStyle(fontWeight: FontWeight.w600), + style: TextStyle( + fontWeight: FontWeight.w600, + color: titleColor, + ), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1055,4 +1090,4 @@ class _QuickActionButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart b/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart index a7bf337..6f30f25 100644 --- a/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart +++ b/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart @@ -514,6 +514,7 @@ class _RoomSpeedTestSelectorState extends ConsumerState { builder: (context) => SpeedTestPopup( cachedTest: selectedConfig, existingResult: currentResult, + apId: testedViaAccessPointId, onCompleted: () { _loadSpeedTests(); }, diff --git a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart index 2a7dd30..fc4fdaf 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:rgnets_fdk/core/theme/app_colors.dart'; -import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; +import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/core/theme/app_colors.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; class SpeedTestPopup extends ConsumerStatefulWidget { @@ -20,12 +20,16 @@ class SpeedTestPopup extends ConsumerStatefulWidget { /// Existing result to update (instead of creating a new one) final SpeedTestResult? existingResult; + /// Optional AP ID to display uplink speed (for AP speed tests) + final int? apId; + const SpeedTestPopup({ super.key, this.cachedTest, this.onCompleted, this.onResultSubmitted, this.existingResult, + this.apId, }); @override @@ -558,6 +562,79 @@ class _SpeedTestPopupState extends ConsumerState ], ], ), + // Uplink speed row (for AP speed tests) + if (widget.apId != null) ...[ + const SizedBox(height: 6), + Consumer( + builder: (context, ref, _) { + final uplinkAsync = + ref.watch(apUplinkInfoProvider(widget.apId!)); + return Row( + children: [ + const Icon( + Icons.cable, + size: 16, + color: AppColors.gray500, + ), + const SizedBox(width: 8), + const Text( + 'Uplink: ', + style: TextStyle( + fontSize: 12, + color: AppColors.gray500, + ), + ), + uplinkAsync.when( + data: (uplink) { + if (uplink == null) { + return const Text( + 'Not available', + style: TextStyle( + fontSize: 12, + color: AppColors.warning, + ), + ); + } + final speedBps = uplink.speedInBps; + final speedGbps = speedBps != null + ? speedBps / 1000000000 + : null; + final isSlowUplink = speedBps != null && + speedBps < 2500000000; + return Text( + speedGbps != null + ? '${speedGbps.toStringAsFixed(1)} Gbps' + : 'Unknown', + style: TextStyle( + fontSize: 12, + color: isSlowUplink + ? AppColors.error + : AppColors.gray300, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ); + }, + loading: () => const Text( + 'Loading...', + style: TextStyle( + fontSize: 12, + color: AppColors.gray500, + ), + ), + error: (_, __) => const Text( + 'Error', + style: TextStyle( + fontSize: 12, + color: AppColors.error, + ), + ), + ), + ], + ); + }, + ), + ], ], ), ), From 0ee4607e216107166511c146c469a93c4d51da19 Mon Sep 17 00:00:00 2001 From: zew-rgn Date: Tue, 20 Jan 2026 14:51:12 -0800 Subject: [PATCH 12/24] Integrate room readiness status labels into Locations UI (#12) * Add deeplink functionality for FDK app login (#9) * Add deeplink functionality for FDK app login Implement deeplink handling to allow users to click a link (fdk://login?...) to login to the FDK app, similar to the ATT-FE-Tool functionality. Changes: - Add app_links dependency for handling deeplinks - Create DeeplinkService with validation, deduplication, and Base64 support - Create DeeplinkProvider for Riverpod integration - Configure Android intent filters for fdk:// scheme - Configure iOS URL scheme for fdk:// - Add GoRouter redirect to handle deeplink URLs gracefully - Initialize deeplink service in main.dart with confirmation dialog - Add comprehensive unit tests for deeplink functionality The deeplink flow: 1. User clicks fdk://login?fqdn=...&apiKey=...&login=... link 2. App opens and shows CredentialApprovalSheet for confirmation 3. On approval, authenticates via existing auth flow 4. Navigates to home screen on success Co-Authored-By: Claude Opus 4.5 * Removing local files --------- Co-authored-by: Zachary Wilhite Co-authored-by: Claude Opus 4.5 * Fixing author * Integrate room readiness status labels into Locations UI Update Locations view to display proper status labels (Ready/Partial/Down/Empty) instead of generic "Has Issues" text. Integrates RoomStatus from room_readiness feature into RoomViewModel and propagates status-based colors through the UI. Changes: - Add RoomStatus to RoomViewModel with statusText getter - Update RoomStats with partial/down/empty counts - Replace "Has Issues" with status labels in rooms_screen and room_detail_screen - Add status-specific colors (green/orange/red/grey) - Add default staging username fallback in environment.dart - Fix mock stubs in optimization_verification_test.dart - Add unit tests for room view models and staging auth Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Zachary Wilhite Co-authored-by: Claude Opus 4.5 --- .../services/websocket_data_sync_service.dart | 37 +++++++++++++++++ .../room_readiness_data_source.dart | 40 +++++++++++++++++++ .../providers/room_view_models.dart | 6 +++ .../providers/room_view_models.g.dart | 4 ++ .../screens/room_detail_screen.dart | 3 ++ .../presentation/screens/rooms_screen.dart | 3 ++ 6 files changed, 93 insertions(+) diff --git a/lib/core/services/websocket_data_sync_service.dart b/lib/core/services/websocket_data_sync_service.dart index 69fc3bc..5f41647 100644 --- a/lib/core/services/websocket_data_sync_service.dart +++ b/lib/core/services/websocket_data_sync_service.dart @@ -786,6 +786,40 @@ class WebSocketDataSyncService { final id = entry['id']; if (id != null) { deviceIds.add('$prefix$id'); +<<<<<<< HEAD +======= + } + + final nested = entry['devices']; + if (nested is List) { + for (final device in nested) { + if (device is Map) { + final nestedId = device['id']; + if (nestedId != null) { + deviceIds.add('$prefix$nestedId'); + } + } + } + } + } + } + + void addSwitchPortDevices(List? list) { + if (list == null) { + return; + } + for (final entry in list) { + if (entry is! Map) { + continue; + } + final switchDevice = entry['switch_device']; + final switchDeviceId = switchDevice is Map + ? switchDevice['id'] + : entry['switch_device_id']; + final id = switchDeviceId ?? entry['id']; + if (id != null) { + deviceIds.add('sw_$id'); +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) } final nested = entry['devices']; @@ -802,6 +836,7 @@ class WebSocketDataSyncService { } } +<<<<<<< HEAD void addSwitchPortDevices(List? list) { if (list == null) { return; @@ -833,6 +868,8 @@ class WebSocketDataSyncService { } } +======= +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) addDevices(roomData['access_points'] as List?, prefix: 'ap_'); addDevices(roomData['media_converters'] as List?, prefix: 'ont_'); final switchPorts = roomData['switch_ports']; diff --git a/lib/features/room_readiness/data/datasources/room_readiness_data_source.dart b/lib/features/room_readiness/data/datasources/room_readiness_data_source.dart index fa29fab..9f2ed22 100644 --- a/lib/features/room_readiness/data/datasources/room_readiness_data_source.dart +++ b/lib/features/room_readiness/data/datasources/room_readiness_data_source.dart @@ -181,6 +181,7 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { final roomId = _parseRoomId(roomData['id']); final roomName = _buildRoomName(roomData); +<<<<<<< HEAD // DEBUG: Log room data structure _logger.i('DEBUG ROOM $roomId ($roomName): roomData keys = ${roomData.keys.toList()}'); _logger.i('DEBUG ROOM $roomId: access_points = ${roomData['access_points']}'); @@ -188,12 +189,17 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { _logger.i('DEBUG ROOM $roomId: switch_ports = ${roomData['switch_ports']}'); _logger.i('DEBUG ROOM $roomId: deviceModels count = ${deviceModels.length}'); +======= +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) // Extract device references from room data final deviceRefs = _extractDeviceReferences(roomData); final totalDevices = deviceRefs.length; +<<<<<<< HEAD _logger.i('DEBUG ROOM $roomId: Extracted ${deviceRefs.length} device refs: $deviceRefs'); +======= +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) if (totalDevices == 0) { return RoomReadinessMetrics( roomId: roomId, @@ -214,9 +220,13 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { for (final ref in deviceRefs) { final device = _findDevice(ref, deviceModels); +<<<<<<< HEAD _logger.i('DEBUG ROOM $roomId: Finding device ref=${ref['id']} type=${ref['type']} -> found=${device != null}'); if (device == null) { _logger.w('DEBUG ROOM $roomId: DEVICE NOT FOUND - ref=$ref'); +======= + if (device == null) { +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) // Device reference exists but device not found - critical issue issues.add( Issue( @@ -366,10 +376,14 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { dynamic _findDevice(Map ref, List deviceModels) { final refId = ref['id']?.toString(); final refType = ref['type'] as String?; +<<<<<<< HEAD if (refId == null) { _logger.w('DEBUG _findDevice: refId is null for ref=$ref'); return null; } +======= + if (refId == null) return null; +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) // Build expected device ID with prefix final prefix = switch (refType) { @@ -380,6 +394,7 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { }; final prefixedId = '$prefix$refId'; +<<<<<<< HEAD _logger.i('DEBUG _findDevice: Looking for refId=$refId prefixedId=$prefixedId in ${deviceModels.length} devices'); // Log first few device IDs for comparison @@ -394,18 +409,26 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { _logger.i('DEBUG _findDevice: Sample device IDs in cache: $sampleIds'); } +======= +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) for (final device in deviceModels) { try { final deviceId = device.id as String?; if (deviceId == prefixedId || deviceId == refId) { +<<<<<<< HEAD _logger.i('DEBUG _findDevice: MATCH FOUND deviceId=$deviceId'); +======= +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) return device; } } catch (_) { // Handle case where device is a Map if (device is Map) { if (device['id']?.toString() == refId) { +<<<<<<< HEAD _logger.i('DEBUG _findDevice: MATCH FOUND (Map) id=${device['id']}'); +======= +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) return device; } } @@ -415,11 +438,17 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { // Also check in the reference data itself (inline device data) final refData = ref['data']; if (refData is Map && refData.containsKey('online')) { +<<<<<<< HEAD _logger.i('DEBUG _findDevice: Using inline refData with online=${refData['online']}'); return refData; } _logger.w('DEBUG _findDevice: NO MATCH for refId=$refId prefixedId=$prefixedId'); +======= + return refData; + } + +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) return null; } @@ -507,6 +536,7 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { return RoomStatus.empty; } +<<<<<<< HEAD // Check for device-missing issues (always DOWN - device expected but not found) final hasMissingDevice = issues.any((i) => i.code == 'DEVICE_MISSING'); if (hasMissingDevice) { @@ -520,6 +550,16 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { // PARTIAL if some devices offline OR any issues exist if (onlineDevices < totalDevices || issues.isNotEmpty) { +======= + // Check for critical issues + final hasCritical = issues.any((i) => i.severity == IssueSeverity.critical); + if (hasCritical) { + return RoomStatus.down; + } + + // Check for any non-critical issues + if (issues.isNotEmpty) { +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) return RoomStatus.partial; } diff --git a/lib/features/rooms/presentation/providers/room_view_models.dart b/lib/features/rooms/presentation/providers/room_view_models.dart index eb07f87..0ce1f32 100644 --- a/lib/features/rooms/presentation/providers/room_view_models.dart +++ b/lib/features/rooms/presentation/providers/room_view_models.dart @@ -3,7 +3,10 @@ import 'package:rgnets_fdk/features/devices/domain/entities/room.dart'; import 'package:rgnets_fdk/features/devices/presentation/providers/devices_provider.dart'; import 'package:rgnets_fdk/features/room_readiness/domain/entities/room_readiness.dart'; import 'package:rgnets_fdk/features/room_readiness/presentation/providers/room_readiness_provider.dart'; +<<<<<<< HEAD import 'package:rgnets_fdk/features/rooms/presentation/providers/room_ui_state_provider.dart'; +======= +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) import 'package:rgnets_fdk/features/rooms/presentation/providers/rooms_riverpod_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -161,6 +164,7 @@ List filteredRoomViewModels( filtered = List.from(viewModels); } +<<<<<<< HEAD // Apply search filter if (searchQuery.isNotEmpty) { filtered = filtered.where((vm) { @@ -169,6 +173,8 @@ List filteredRoomViewModels( }).toList(); } +======= +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) // Sort by room name alphabetically filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); diff --git a/lib/features/rooms/presentation/providers/room_view_models.g.dart b/lib/features/rooms/presentation/providers/room_view_models.g.dart index 932578e..4338250 100644 --- a/lib/features/rooms/presentation/providers/room_view_models.g.dart +++ b/lib/features/rooms/presentation/providers/room_view_models.g.dart @@ -187,7 +187,11 @@ class _RoomViewModelByIdProviderElement } String _$filteredRoomViewModelsHash() => +<<<<<<< HEAD r'6fe8852fd0d485183dde0dee1d150dba626a92bc'; +======= + r'c4f8d9f36ebbf2eea084e9f4a5d45494abf7819a'; +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) /// Provider for filtered room view models /// diff --git a/lib/features/rooms/presentation/screens/room_detail_screen.dart b/lib/features/rooms/presentation/screens/room_detail_screen.dart index c33c71c..702bab2 100644 --- a/lib/features/rooms/presentation/screens/room_detail_screen.dart +++ b/lib/features/rooms/presentation/screens/room_detail_screen.dart @@ -8,7 +8,10 @@ import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/widgets/widgets.dart'; import 'package:rgnets_fdk/features/devices/domain/constants/device_types.dart'; import 'package:rgnets_fdk/features/devices/presentation/providers/devices_provider.dart'; +<<<<<<< HEAD import 'package:rgnets_fdk/features/onboarding/presentation/widgets/onboarding_stage_badge.dart'; +======= +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) import 'package:rgnets_fdk/features/room_readiness/domain/entities/room_readiness.dart'; import 'package:rgnets_fdk/features/rooms/presentation/providers/room_device_view_model.dart'; import 'package:rgnets_fdk/features/rooms/presentation/providers/room_view_models.dart'; diff --git a/lib/features/rooms/presentation/screens/rooms_screen.dart b/lib/features/rooms/presentation/screens/rooms_screen.dart index 68881a1..04d8aac 100644 --- a/lib/features/rooms/presentation/screens/rooms_screen.dart +++ b/lib/features/rooms/presentation/screens/rooms_screen.dart @@ -6,7 +6,10 @@ import 'package:rgnets_fdk/core/widgets/hud_tab_bar.dart'; import 'package:rgnets_fdk/core/widgets/unified_list/unified_list_item.dart'; import 'package:rgnets_fdk/core/widgets/widgets.dart'; import 'package:rgnets_fdk/features/room_readiness/domain/entities/room_readiness.dart'; +<<<<<<< HEAD import 'package:rgnets_fdk/features/rooms/presentation/providers/room_ui_state_provider.dart'; +======= +>>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) import 'package:rgnets_fdk/features/rooms/presentation/providers/room_view_models.dart'; import 'package:rgnets_fdk/features/rooms/presentation/providers/rooms_riverpod_provider.dart'; From 7f371173848fd0c0ae7369333b85ec332084d2ad Mon Sep 17 00:00:00 2001 From: zew-rgn Date: Tue, 20 Jan 2026 16:24:42 -0800 Subject: [PATCH 13/24] Add deployment phase filtering (#13) * Add deeplink functionality for FDK app login (#9) * Add deeplink functionality for FDK app login Implement deeplink handling to allow users to click a link (fdk://login?...) to login to the FDK app, similar to the ATT-FE-Tool functionality. Changes: - Add app_links dependency for handling deeplinks - Create DeeplinkService with validation, deduplication, and Base64 support - Create DeeplinkProvider for Riverpod integration - Configure Android intent filters for fdk:// scheme - Configure iOS URL scheme for fdk:// - Add GoRouter redirect to handle deeplink URLs gracefully - Initialize deeplink service in main.dart with confirmation dialog - Add comprehensive unit tests for deeplink functionality The deeplink flow: 1. User clicks fdk://login?fqdn=...&apiKey=...&login=... link 2. App opens and shows CredentialApprovalSheet for confirmation 3. On approval, authenticates via existing auth flow 4. Navigates to home screen on success Co-Authored-By: Claude Opus 4.5 * Removing local files --------- Co-authored-by: Zachary Wilhite Co-authored-by: Claude Opus 4.5 * Fixing author * Integrate room readiness status labels into Locations UI Update Locations view to display proper status labels (Ready/Partial/Down/Empty) instead of generic "Has Issues" text. Integrates RoomStatus from room_readiness feature into RoomViewModel and propagates status-based colors through the UI. Changes: - Add RoomStatus to RoomViewModel with statusText getter - Update RoomStats with partial/down/empty counts - Replace "Has Issues" with status labels in rooms_screen and room_detail_screen - Add status-specific colors (green/orange/red/grey) - Add default staging username fallback in environment.dart - Fix mock stubs in optimization_verification_test.dart - Add unit tests for room view models and staging auth Co-Authored-By: Claude Opus 4.5 * Add deployment phase filtering with persistence Implement phase filtering for devices with session persistence: - Add PhaseFilterState and PhaseFilterNotifier for state management - Filter devices by phase using metadata['phase'] field - Persist selected phase filter to SharedPreferences - Add PopupMenuButton dropdown UI in devices screen - Support "All Phases" and "Unassigned" special filters - Use ref.listen() to sync phase changes without resetting UI state Changes: - lib/features/devices/presentation/providers/phase_filter_provider.dart (new) - lib/features/devices/presentation/providers/device_ui_state_provider.dart - lib/features/devices/presentation/screens/devices_screen.dart - lib/core/services/storage_service.dart - test/features/devices/presentation/providers/phase_filter_test.dart (new) Co-Authored-By: Claude Opus 4.5 * Fix issue with metadata --------- Co-authored-by: Zachary Wilhite Co-authored-by: Claude Opus 4.5 --- lib/core/services/websocket_cache_integration.dart | 7 +++++++ .../presentation/providers/device_ui_state_provider.g.dart | 4 ++++ .../devices/presentation/screens/devices_screen.dart | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/lib/core/services/websocket_cache_integration.dart b/lib/core/services/websocket_cache_integration.dart index db00869..0578216 100644 --- a/lib/core/services/websocket_cache_integration.dart +++ b/lib/core/services/websocket_cache_integration.dart @@ -688,7 +688,11 @@ class WebSocketCacheIntegration { infrastructureLinkId: _parseOptionalInt( deviceMap['infrastructure_link_id'], ), +<<<<<<< HEAD >>>>>>> 3bdf0aa (Uplink added) +======= + metadata: deviceMap, +>>>>>>> 81c4e9a (Add deployment phase filtering (#13)) ); case 'media_converters': @@ -708,11 +712,14 @@ class WebSocketCacheIntegration { hnCounts: hnCounts, healthNotices: healthNotices, metadata: deviceMap, +<<<<<<< HEAD onboardingStatus: deviceMap['ont_onboarding_status'] != null ? OnboardingStatusPayload.fromJson( deviceMap['ont_onboarding_status'] as Map, ) : null, +======= +>>>>>>> 81c4e9a (Add deployment phase filtering (#13)) ); case 'switch_devices': diff --git a/lib/features/devices/presentation/providers/device_ui_state_provider.g.dart b/lib/features/devices/presentation/providers/device_ui_state_provider.g.dart index 562bb51..f9d99f9 100644 --- a/lib/features/devices/presentation/providers/device_ui_state_provider.g.dart +++ b/lib/features/devices/presentation/providers/device_ui_state_provider.g.dart @@ -7,7 +7,11 @@ part of 'device_ui_state_provider.dart'; // ************************************************************************** String _$filteredDevicesListHash() => +<<<<<<< HEAD r'dba3450757fbf31ea5312471972cef46c17bdb11'; +======= + r'eb08d75fe18e5b6dddaf44ecb541ccd0d035b2c6'; +>>>>>>> 81c4e9a (Add deployment phase filtering (#13)) /// Provider for filtered devices based on UI state /// diff --git a/lib/features/devices/presentation/screens/devices_screen.dart b/lib/features/devices/presentation/screens/devices_screen.dart index 1bf0573..1952ff1 100644 --- a/lib/features/devices/presentation/screens/devices_screen.dart +++ b/lib/features/devices/presentation/screens/devices_screen.dart @@ -291,6 +291,7 @@ class _DevicesScreenState extends ConsumerState { ], ), ), +<<<<<<< HEAD // Search bar SearchBarWidget( @@ -304,6 +305,9 @@ class _DevicesScreenState extends ConsumerState { }, ), +======= + +>>>>>>> 81c4e9a (Add deployment phase filtering (#13)) // Phase filter bar _buildPhaseFilterBar(ref, devices), From 3508a933973438fa774839f4b719130d9c6004b4 Mon Sep 17 00:00:00 2001 From: zew-rgn Date: Wed, 21 Jan 2026 11:41:01 -0800 Subject: [PATCH 14/24] Json credential and room readiness (#18) --- .../room_readiness_data_source.dart | 59 +++++++++++++++++++ .../screens/room_detail_screen.dart | 4 ++ 2 files changed, 63 insertions(+) diff --git a/lib/features/room_readiness/data/datasources/room_readiness_data_source.dart b/lib/features/room_readiness/data/datasources/room_readiness_data_source.dart index 9f2ed22..ce86869 100644 --- a/lib/features/room_readiness/data/datasources/room_readiness_data_source.dart +++ b/lib/features/room_readiness/data/datasources/room_readiness_data_source.dart @@ -182,6 +182,9 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { final roomName = _buildRoomName(roomData); <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> 47e623e (Json credential and room readiness (#18)) // DEBUG: Log room data structure _logger.i('DEBUG ROOM $roomId ($roomName): roomData keys = ${roomData.keys.toList()}'); _logger.i('DEBUG ROOM $roomId: access_points = ${roomData['access_points']}'); @@ -189,17 +192,25 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { _logger.i('DEBUG ROOM $roomId: switch_ports = ${roomData['switch_ports']}'); _logger.i('DEBUG ROOM $roomId: deviceModels count = ${deviceModels.length}'); +<<<<<<< HEAD ======= >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= +>>>>>>> 47e623e (Json credential and room readiness (#18)) // Extract device references from room data final deviceRefs = _extractDeviceReferences(roomData); final totalDevices = deviceRefs.length; +<<<<<<< HEAD <<<<<<< HEAD _logger.i('DEBUG ROOM $roomId: Extracted ${deviceRefs.length} device refs: $deviceRefs'); ======= >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= + _logger.i('DEBUG ROOM $roomId: Extracted ${deviceRefs.length} device refs: $deviceRefs'); + +>>>>>>> 47e623e (Json credential and room readiness (#18)) if (totalDevices == 0) { return RoomReadinessMetrics( roomId: roomId, @@ -220,6 +231,7 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { for (final ref in deviceRefs) { final device = _findDevice(ref, deviceModels); +<<<<<<< HEAD <<<<<<< HEAD _logger.i('DEBUG ROOM $roomId: Finding device ref=${ref['id']} type=${ref['type']} -> found=${device != null}'); if (device == null) { @@ -227,6 +239,11 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { ======= if (device == null) { >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= + _logger.i('DEBUG ROOM $roomId: Finding device ref=${ref['id']} type=${ref['type']} -> found=${device != null}'); + if (device == null) { + _logger.w('DEBUG ROOM $roomId: DEVICE NOT FOUND - ref=$ref'); +>>>>>>> 47e623e (Json credential and room readiness (#18)) // Device reference exists but device not found - critical issue issues.add( Issue( @@ -377,13 +394,19 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { final refId = ref['id']?.toString(); final refType = ref['type'] as String?; <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> 47e623e (Json credential and room readiness (#18)) if (refId == null) { _logger.w('DEBUG _findDevice: refId is null for ref=$ref'); return null; } +<<<<<<< HEAD ======= if (refId == null) return null; >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= +>>>>>>> 47e623e (Json credential and room readiness (#18)) // Build expected device ID with prefix final prefix = switch (refType) { @@ -395,6 +418,9 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { final prefixedId = '$prefix$refId'; <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> 47e623e (Json credential and room readiness (#18)) _logger.i('DEBUG _findDevice: Looking for refId=$refId prefixedId=$prefixedId in ${deviceModels.length} devices'); // Log first few device IDs for comparison @@ -409,26 +435,37 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { _logger.i('DEBUG _findDevice: Sample device IDs in cache: $sampleIds'); } +<<<<<<< HEAD ======= >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= +>>>>>>> 47e623e (Json credential and room readiness (#18)) for (final device in deviceModels) { try { final deviceId = device.id as String?; if (deviceId == prefixedId || deviceId == refId) { +<<<<<<< HEAD <<<<<<< HEAD _logger.i('DEBUG _findDevice: MATCH FOUND deviceId=$deviceId'); ======= >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= + _logger.i('DEBUG _findDevice: MATCH FOUND deviceId=$deviceId'); +>>>>>>> 47e623e (Json credential and room readiness (#18)) return device; } } catch (_) { // Handle case where device is a Map if (device is Map) { if (device['id']?.toString() == refId) { +<<<<<<< HEAD <<<<<<< HEAD _logger.i('DEBUG _findDevice: MATCH FOUND (Map) id=${device['id']}'); ======= >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= + _logger.i('DEBUG _findDevice: MATCH FOUND (Map) id=${device['id']}'); +>>>>>>> 47e623e (Json credential and room readiness (#18)) return device; } } @@ -439,16 +476,22 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { final refData = ref['data']; if (refData is Map && refData.containsKey('online')) { <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> 47e623e (Json credential and room readiness (#18)) _logger.i('DEBUG _findDevice: Using inline refData with online=${refData['online']}'); return refData; } _logger.w('DEBUG _findDevice: NO MATCH for refId=$refId prefixedId=$prefixedId'); +<<<<<<< HEAD ======= return refData; } >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= +>>>>>>> 47e623e (Json credential and room readiness (#18)) return null; } @@ -536,6 +579,7 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { return RoomStatus.empty; } +<<<<<<< HEAD <<<<<<< HEAD // Check for device-missing issues (always DOWN - device expected but not found) final hasMissingDevice = issues.any((i) => i.code == 'DEVICE_MISSING'); @@ -560,6 +604,21 @@ class RoomReadinessWebSocketDataSource implements RoomReadinessDataSource { // Check for any non-critical issues if (issues.isNotEmpty) { >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= + // Check for device-missing issues (always DOWN - device expected but not found) + final hasMissingDevice = issues.any((i) => i.code == 'DEVICE_MISSING'); + if (hasMissingDevice) { + return RoomStatus.down; + } + + // DOWN only if ALL devices are offline + if (onlineDevices == 0) { + return RoomStatus.down; + } + + // PARTIAL if some devices offline OR any issues exist + if (onlineDevices < totalDevices || issues.isNotEmpty) { +>>>>>>> 47e623e (Json credential and room readiness (#18)) return RoomStatus.partial; } diff --git a/lib/features/rooms/presentation/screens/room_detail_screen.dart b/lib/features/rooms/presentation/screens/room_detail_screen.dart index 702bab2..5759d7c 100644 --- a/lib/features/rooms/presentation/screens/room_detail_screen.dart +++ b/lib/features/rooms/presentation/screens/room_detail_screen.dart @@ -317,11 +317,15 @@ class _RoomHeader extends StatelessWidget { } } +<<<<<<< HEAD <<<<<<< HEAD class _OverviewTab extends ConsumerWidget { ======= class _OverviewTab extends StatelessWidget { >>>>>>> 24906fa (Add pms speed test) +======= +class _OverviewTab extends ConsumerWidget { +>>>>>>> 47e623e (Json credential and room readiness (#18)) const _OverviewTab({required this.roomVm}); final RoomViewModel roomVm; From 64ef2ca90f870ae7b594c038b755dbe59547819c Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Fri, 16 Jan 2026 00:52:20 -0800 Subject: [PATCH 15/24] Attempt --- .../models/device_model_sealed.freezed.dart | 61 ++++++------------- .../data/models/device_model_sealed.g.dart | 2 - 2 files changed, 18 insertions(+), 45 deletions(-) diff --git a/lib/features/devices/data/models/device_model_sealed.freezed.dart b/lib/features/devices/data/models/device_model_sealed.freezed.dart index f7d903b..48d360f 100644 --- a/lib/features/devices/data/models/device_model_sealed.freezed.dart +++ b/lib/features/devices/data/models/device_model_sealed.freezed.dart @@ -33,6 +33,7 @@ DeviceModelSealed _$DeviceModelSealedFromJson(Map json) { /// @nodoc mixin _$DeviceModelSealed { +// Common fields String get id => throw _privateConstructorUsedError; String get name => throw _privateConstructorUsedError; String get status => throw _privateConstructorUsedError; @@ -80,7 +81,6 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -198,7 +198,6 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -316,7 +315,6 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -628,7 +626,6 @@ abstract class _$$APModelImplCopyWith<$Res> List? images, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -674,7 +671,6 @@ class __$$APModelImplCopyWithImpl<$Res> Object? images = freezed, Object? healthNotices = freezed, Object? hnCounts = freezed, - Object? infrastructureLinkId = freezed, Object? connectionState = freezed, Object? signalStrength = freezed, Object? connectedClients = freezed, @@ -754,10 +750,6 @@ class __$$APModelImplCopyWithImpl<$Res> ? _value.hnCounts : hnCounts // ignore: cast_nullable_to_non_nullable as HealthCountsModel?, - infrastructureLinkId: freezed == infrastructureLinkId - ? _value.infrastructureLinkId - : infrastructureLinkId // ignore: cast_nullable_to_non_nullable - as int?, connectionState: freezed == connectionState ? _value.connectionState : connectionState // ignore: cast_nullable_to_non_nullable @@ -820,7 +812,6 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') this.hnCounts, - @JsonKey(name: 'infrastructure_link_id') this.infrastructureLinkId, @JsonKey(name: 'connection_state') this.connectionState, @JsonKey(name: 'signal_strength') this.signalStrength, @JsonKey(name: 'connected_clients') this.connectedClients, @@ -842,6 +833,7 @@ class _$APModelImpl extends APModel { factory _$APModelImpl.fromJson(Map json) => _$$APModelImplFromJson(json); +// Common fields @override final String id; @override @@ -908,9 +900,7 @@ class _$APModelImpl extends APModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; - @override - @JsonKey(name: 'infrastructure_link_id') - final int? infrastructureLinkId; +// AP-specific fields @override @JsonKey(name: 'connection_state') final String? connectionState; @@ -949,7 +939,7 @@ class _$APModelImpl extends APModel { @override String toString() { - return 'DeviceModelSealed.ap(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, healthNotices: $healthNotices, hnCounts: $hnCounts, infrastructureLinkId: $infrastructureLinkId, connectionState: $connectionState, signalStrength: $signalStrength, connectedClients: $connectedClients, ssid: $ssid, channel: $channel, maxClients: $maxClients, currentUpload: $currentUpload, currentDownload: $currentDownload, onboardingStatus: $onboardingStatus)'; + return 'DeviceModelSealed.ap(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, healthNotices: $healthNotices, hnCounts: $hnCounts, connectionState: $connectionState, signalStrength: $signalStrength, connectedClients: $connectedClients, ssid: $ssid, channel: $channel, maxClients: $maxClients, currentUpload: $currentUpload, currentDownload: $currentDownload, onboardingStatus: $onboardingStatus)'; } @override @@ -983,8 +973,6 @@ class _$APModelImpl extends APModel { .equals(other._healthNotices, _healthNotices) && (identical(other.hnCounts, hnCounts) || other.hnCounts == hnCounts) && - (identical(other.infrastructureLinkId, infrastructureLinkId) || - other.infrastructureLinkId == infrastructureLinkId) && (identical(other.connectionState, connectionState) || other.connectionState == connectionState) && (identical(other.signalStrength, signalStrength) || @@ -1024,7 +1012,6 @@ class _$APModelImpl extends APModel { const DeepCollectionEquality().hash(_images), const DeepCollectionEquality().hash(_healthNotices), hnCounts, - infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1064,7 +1051,6 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -1178,7 +1164,6 @@ class _$APModelImpl extends APModel { images, healthNotices, hnCounts, - infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1212,7 +1197,6 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -1326,7 +1310,6 @@ class _$APModelImpl extends APModel { images, healthNotices, hnCounts, - infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1360,7 +1343,6 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -1476,7 +1458,6 @@ class _$APModelImpl extends APModel { images, healthNotices, hnCounts, - infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1555,7 +1536,6 @@ abstract class APModel extends DeviceModelSealed { @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') final int? infrastructureLinkId, @JsonKey(name: 'connection_state') final String? connectionState, @JsonKey(name: 'signal_strength') final int? signalStrength, @JsonKey(name: 'connected_clients') final int? connectedClients, @@ -1570,7 +1550,7 @@ abstract class APModel extends DeviceModelSealed { factory APModel.fromJson(Map json) = _$APModelImpl.fromJson; - @override + @override // Common fields String get id; @override String get name; @@ -1611,9 +1591,7 @@ abstract class APModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; - @JsonKey(name: 'infrastructure_link_id') - int? get infrastructureLinkId; + HealthCountsModel? get hnCounts; // AP-specific fields @JsonKey(name: 'connection_state') String? get connectionState; @JsonKey(name: 'signal_strength') @@ -1850,6 +1828,7 @@ class _$ONTModelImpl extends ONTModel { factory _$ONTModelImpl.fromJson(Map json) => _$$ONTModelImplFromJson(json); +// Common fields @override final String id; @override @@ -1916,6 +1895,7 @@ class _$ONTModelImpl extends ONTModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; +// ONT-specific fields @override @JsonKey(name: 'is_registered') final bool? isRegistered; @@ -2064,7 +2044,6 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -2208,7 +2187,6 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -2352,7 +2330,6 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -2555,7 +2532,7 @@ abstract class ONTModel extends DeviceModelSealed { factory ONTModel.fromJson(Map json) = _$ONTModelImpl.fromJson; - @override + @override // Common fields String get id; @override String get name; @@ -2596,7 +2573,7 @@ abstract class ONTModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; + HealthCountsModel? get hnCounts; // ONT-specific fields @JsonKey(name: 'is_registered') bool? get isRegistered; @JsonKey(name: 'switch_port') @@ -2831,6 +2808,7 @@ class _$SwitchModelImpl extends SwitchModel { factory _$SwitchModelImpl.fromJson(Map json) => _$$SwitchModelImplFromJson(json); +// Common fields @override final String id; @override @@ -2897,6 +2875,7 @@ class _$SwitchModelImpl extends SwitchModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; +// Switch-specific fields @override final String? host; final List>? _ports; @@ -3036,7 +3015,6 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -3181,7 +3159,6 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -3326,7 +3303,6 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -3531,7 +3507,7 @@ abstract class SwitchModel extends DeviceModelSealed { factory SwitchModel.fromJson(Map json) = _$SwitchModelImpl.fromJson; - @override + @override // Common fields String get id; @override String get name; @@ -3572,7 +3548,7 @@ abstract class SwitchModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; + HealthCountsModel? get hnCounts; // Switch-specific fields String? get host; @JsonKey(name: 'switch_ports') List>? get ports; @@ -3814,6 +3790,7 @@ class _$WLANModelImpl extends WLANModel { factory _$WLANModelImpl.fromJson(Map json) => _$$WLANModelImplFromJson(json); +// Common fields @override final String id; @override @@ -3880,6 +3857,7 @@ class _$WLANModelImpl extends WLANModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; +// WLAN-specific fields @override @JsonKey(name: 'controller_type') final String? controllerType; @@ -4017,7 +3995,6 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -4163,7 +4140,6 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -4309,7 +4285,6 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, - @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -4516,7 +4491,7 @@ abstract class WLANModel extends DeviceModelSealed { factory WLANModel.fromJson(Map json) = _$WLANModelImpl.fromJson; - @override + @override // Common fields String get id; @override String get name; @@ -4557,7 +4532,7 @@ abstract class WLANModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; + HealthCountsModel? get hnCounts; // WLAN-specific fields @JsonKey(name: 'controller_type') String? get controllerType; @JsonKey(name: 'managed_aps') diff --git a/lib/features/devices/data/models/device_model_sealed.g.dart b/lib/features/devices/data/models/device_model_sealed.g.dart index 13d2159..9ada501 100644 --- a/lib/features/devices/data/models/device_model_sealed.g.dart +++ b/lib/features/devices/data/models/device_model_sealed.g.dart @@ -35,7 +35,6 @@ _$APModelImpl _$$APModelImplFromJson(Map json) => ? null : HealthCountsModel.fromJson( json['hn_counts'] as Map), - infrastructureLinkId: (json['infrastructure_link_id'] as num?)?.toInt(), connectionState: json['connection_state'] as String?, signalStrength: (json['signal_strength'] as num?)?.toInt(), connectedClients: (json['connected_clients'] as num?)?.toInt(), @@ -76,7 +75,6 @@ Map _$$APModelImplToJson(_$APModelImpl instance) { writeNotNull('health_notices', instance.healthNotices?.map((e) => e.toJson()).toList()); writeNotNull('hn_counts', instance.hnCounts?.toJson()); - writeNotNull('infrastructure_link_id', instance.infrastructureLinkId); writeNotNull('connection_state', instance.connectionState); writeNotNull('signal_strength', instance.signalStrength); writeNotNull('connected_clients', instance.connectedClients); From e9b3ad5ce3da7bd6b2d32496279b2c327eee8532 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Thu, 22 Jan 2026 10:09:31 -0800 Subject: [PATCH 16/24] Draft for device onboarding quit --- .../models/device_model_sealed.freezed.dart | 147 ++++++++++-------- .../data/models/device_model_sealed.g.dart | 14 +- .../screens/device_detail_screen.dart | 3 + .../device_onboarding_provider.g.dart | 4 + .../widgets/onboarding_status_card.dart | 130 ++++++++++++++++ .../screens/room_detail_screen.dart | 4 + lib/main.dart | 22 +++ pubspec.yaml | 5 + 8 files changed, 256 insertions(+), 73 deletions(-) diff --git a/lib/features/devices/data/models/device_model_sealed.freezed.dart b/lib/features/devices/data/models/device_model_sealed.freezed.dart index 48d360f..ca159c2 100644 --- a/lib/features/devices/data/models/device_model_sealed.freezed.dart +++ b/lib/features/devices/data/models/device_model_sealed.freezed.dart @@ -90,7 +90,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus) + OnboardingStatusPayload? onboardingStatus) ap, required TResult Function( String id, @@ -114,7 +114,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase) @@ -207,7 +207,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus)? + OnboardingStatusPayload? onboardingStatus)? ap, TResult? Function( String id, @@ -231,7 +231,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -324,7 +324,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus)? + OnboardingStatusPayload? onboardingStatus)? ap, TResult Function( String id, @@ -348,7 +348,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -635,12 +635,13 @@ abstract class _$$APModelImplCopyWith<$Res> @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus}); + OnboardingStatusPayload? onboardingStatus}); @override $RoomModelCopyWith<$Res>? get pmsRoom; @override $HealthCountsModelCopyWith<$Res>? get hnCounts; + $OnboardingStatusPayloadCopyWith<$Res>? get onboardingStatus; } /// @nodoc @@ -783,11 +784,24 @@ class __$$APModelImplCopyWithImpl<$Res> : currentDownload // ignore: cast_nullable_to_non_nullable as double?, onboardingStatus: freezed == onboardingStatus - ? _value._onboardingStatus + ? _value.onboardingStatus : onboardingStatus // ignore: cast_nullable_to_non_nullable - as Map?, + as OnboardingStatusPayload?, )); } + + @override + @pragma('vm:prefer-inline') + $OnboardingStatusPayloadCopyWith<$Res>? get onboardingStatus { + if (_value.onboardingStatus == null) { + return null; + } + + return $OnboardingStatusPayloadCopyWith<$Res>(_value.onboardingStatus!, + (value) { + return _then(_value.copyWith(onboardingStatus: value)); + }); + } } /// @nodoc @@ -820,13 +834,11 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'max_clients') this.maxClients, @JsonKey(name: 'current_upload') this.currentUpload, @JsonKey(name: 'current_download') this.currentDownload, - @JsonKey(name: 'ap_onboarding_status') - final Map? onboardingStatus, + @JsonKey(name: 'ap_onboarding_status') this.onboardingStatus, final String? $type}) : _metadata = metadata, _images = images, _healthNotices = healthNotices, - _onboardingStatus = onboardingStatus, $type = $type ?? 'access_point', super._(); @@ -923,16 +935,9 @@ class _$APModelImpl extends APModel { @override @JsonKey(name: 'current_download') final double? currentDownload; - final Map? _onboardingStatus; @override @JsonKey(name: 'ap_onboarding_status') - Map? get onboardingStatus { - final value = _onboardingStatus; - if (value == null) return null; - if (_onboardingStatus is EqualUnmodifiableMapView) return _onboardingStatus; - // ignore: implicit_dynamic_type - return EqualUnmodifiableMapView(value); - } + final OnboardingStatusPayload? onboardingStatus; @JsonKey(name: 'device_type') final String $type; @@ -987,8 +992,8 @@ class _$APModelImpl extends APModel { other.currentUpload == currentUpload) && (identical(other.currentDownload, currentDownload) || other.currentDownload == currentDownload) && - const DeepCollectionEquality() - .equals(other._onboardingStatus, _onboardingStatus)); + (identical(other.onboardingStatus, onboardingStatus) || + other.onboardingStatus == onboardingStatus)); } @JsonKey(ignore: true) @@ -1020,7 +1025,7 @@ class _$APModelImpl extends APModel { maxClients, currentUpload, currentDownload, - const DeepCollectionEquality().hash(_onboardingStatus) + onboardingStatus ]); @JsonKey(ignore: true) @@ -1060,7 +1065,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus) + OnboardingStatusPayload? onboardingStatus) ap, required TResult Function( String id, @@ -1084,7 +1089,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase) @@ -1206,7 +1211,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus)? + OnboardingStatusPayload? onboardingStatus)? ap, TResult? Function( String id, @@ -1230,7 +1235,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -1352,7 +1357,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus)? + OnboardingStatusPayload? onboardingStatus)? ap, TResult Function( String id, @@ -1376,7 +1381,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -1545,7 +1550,7 @@ abstract class APModel extends DeviceModelSealed { @JsonKey(name: 'current_upload') final double? currentUpload, @JsonKey(name: 'current_download') final double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - final Map? onboardingStatus}) = _$APModelImpl; + final OnboardingStatusPayload? onboardingStatus}) = _$APModelImpl; const APModel._() : super._(); factory APModel.fromJson(Map json) = _$APModelImpl.fromJson; @@ -1607,7 +1612,7 @@ abstract class APModel extends DeviceModelSealed { @JsonKey(name: 'current_download') double? get currentDownload; @JsonKey(name: 'ap_onboarding_status') - Map? get onboardingStatus; + OnboardingStatusPayload? get onboardingStatus; @override @JsonKey(ignore: true) _$$APModelImplCopyWith<_$APModelImpl> get copyWith => @@ -1643,7 +1648,7 @@ abstract class _$$ONTModelImplCopyWith<$Res> @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase}); @@ -1652,6 +1657,7 @@ abstract class _$$ONTModelImplCopyWith<$Res> $RoomModelCopyWith<$Res>? get pmsRoom; @override $HealthCountsModelCopyWith<$Res>? get hnCounts; + $OnboardingStatusPayloadCopyWith<$Res>? get onboardingStatus; } /// @nodoc @@ -1767,9 +1773,9 @@ class __$$ONTModelImplCopyWithImpl<$Res> : switchPort // ignore: cast_nullable_to_non_nullable as Map?, onboardingStatus: freezed == onboardingStatus - ? _value._onboardingStatus + ? _value.onboardingStatus : onboardingStatus // ignore: cast_nullable_to_non_nullable - as Map?, + as OnboardingStatusPayload?, ports: freezed == ports ? _value._ports : ports // ignore: cast_nullable_to_non_nullable @@ -1784,6 +1790,19 @@ class __$$ONTModelImplCopyWithImpl<$Res> as String?, )); } + + @override + @pragma('vm:prefer-inline') + $OnboardingStatusPayloadCopyWith<$Res>? get onboardingStatus { + if (_value.onboardingStatus == null) { + return null; + } + + return $OnboardingStatusPayloadCopyWith<$Res>(_value.onboardingStatus!, + (value) { + return _then(_value.copyWith(onboardingStatus: value)); + }); + } } /// @nodoc @@ -1810,8 +1829,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'hn_counts') this.hnCounts, @JsonKey(name: 'is_registered') this.isRegistered, @JsonKey(name: 'switch_port') final Map? switchPort, - @JsonKey(name: 'ont_onboarding_status') - final Map? onboardingStatus, + @JsonKey(name: 'ont_onboarding_status') this.onboardingStatus, @JsonKey(name: 'ont_ports') final List>? ports, this.uptime, this.phase, @@ -1820,7 +1838,6 @@ class _$ONTModelImpl extends ONTModel { _images = images, _healthNotices = healthNotices, _switchPort = switchPort, - _onboardingStatus = onboardingStatus, _ports = ports, $type = $type ?? 'ont', super._(); @@ -1910,17 +1927,9 @@ class _$ONTModelImpl extends ONTModel { return EqualUnmodifiableMapView(value); } - final Map? _onboardingStatus; @override @JsonKey(name: 'ont_onboarding_status') - Map? get onboardingStatus { - final value = _onboardingStatus; - if (value == null) return null; - if (_onboardingStatus is EqualUnmodifiableMapView) return _onboardingStatus; - // ignore: implicit_dynamic_type - return EqualUnmodifiableMapView(value); - } - + final OnboardingStatusPayload? onboardingStatus; final List>? _ports; @override @JsonKey(name: 'ont_ports') @@ -1980,8 +1989,8 @@ class _$ONTModelImpl extends ONTModel { other.isRegistered == isRegistered) && const DeepCollectionEquality() .equals(other._switchPort, _switchPort) && - const DeepCollectionEquality() - .equals(other._onboardingStatus, _onboardingStatus) && + (identical(other.onboardingStatus, onboardingStatus) || + other.onboardingStatus == onboardingStatus) && const DeepCollectionEquality().equals(other._ports, _ports) && (identical(other.uptime, uptime) || other.uptime == uptime) && (identical(other.phase, phase) || other.phase == phase)); @@ -2010,7 +2019,7 @@ class _$ONTModelImpl extends ONTModel { hnCounts, isRegistered, const DeepCollectionEquality().hash(_switchPort), - const DeepCollectionEquality().hash(_onboardingStatus), + onboardingStatus, const DeepCollectionEquality().hash(_ports), uptime, phase @@ -2053,7 +2062,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus) + OnboardingStatusPayload? onboardingStatus) ap, required TResult Function( String id, @@ -2077,7 +2086,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase) @@ -2196,7 +2205,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus)? + OnboardingStatusPayload? onboardingStatus)? ap, TResult? Function( String id, @@ -2220,7 +2229,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -2339,7 +2348,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus)? + OnboardingStatusPayload? onboardingStatus)? ap, TResult Function( String id, @@ -2363,7 +2372,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -2523,7 +2532,7 @@ abstract class ONTModel extends DeviceModelSealed { @JsonKey(name: 'is_registered') final bool? isRegistered, @JsonKey(name: 'switch_port') final Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - final Map? onboardingStatus, + final OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') final List>? ports, final String? uptime, final String? phase}) = _$ONTModelImpl; @@ -2579,7 +2588,7 @@ abstract class ONTModel extends DeviceModelSealed { @JsonKey(name: 'switch_port') Map? get switchPort; @JsonKey(name: 'ont_onboarding_status') - Map? get onboardingStatus; + OnboardingStatusPayload? get onboardingStatus; @JsonKey(name: 'ont_ports') List>? get ports; String? get uptime; @@ -3024,7 +3033,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus) + OnboardingStatusPayload? onboardingStatus) ap, required TResult Function( String id, @@ -3048,7 +3057,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase) @@ -3168,7 +3177,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus)? + OnboardingStatusPayload? onboardingStatus)? ap, TResult? Function( String id, @@ -3192,7 +3201,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -3312,7 +3321,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus)? + OnboardingStatusPayload? onboardingStatus)? ap, TResult Function( String id, @@ -3336,7 +3345,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -4004,7 +4013,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus) + OnboardingStatusPayload? onboardingStatus) ap, required TResult Function( String id, @@ -4028,7 +4037,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase) @@ -4149,7 +4158,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus)? + OnboardingStatusPayload? onboardingStatus)? ap, TResult? Function( String id, @@ -4173,7 +4182,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? @@ -4294,7 +4303,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'current_upload') double? currentUpload, @JsonKey(name: 'current_download') double? currentDownload, @JsonKey(name: 'ap_onboarding_status') - Map? onboardingStatus)? + OnboardingStatusPayload? onboardingStatus)? ap, TResult Function( String id, @@ -4318,7 +4327,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'is_registered') bool? isRegistered, @JsonKey(name: 'switch_port') Map? switchPort, @JsonKey(name: 'ont_onboarding_status') - Map? onboardingStatus, + OnboardingStatusPayload? onboardingStatus, @JsonKey(name: 'ont_ports') List>? ports, String? uptime, String? phase)? diff --git a/lib/features/devices/data/models/device_model_sealed.g.dart b/lib/features/devices/data/models/device_model_sealed.g.dart index 9ada501..da0ac4e 100644 --- a/lib/features/devices/data/models/device_model_sealed.g.dart +++ b/lib/features/devices/data/models/device_model_sealed.g.dart @@ -43,7 +43,10 @@ _$APModelImpl _$$APModelImplFromJson(Map json) => maxClients: (json['max_clients'] as num?)?.toInt(), currentUpload: (json['current_upload'] as num?)?.toDouble(), currentDownload: (json['current_download'] as num?)?.toDouble(), - onboardingStatus: json['ap_onboarding_status'] as Map?, + onboardingStatus: json['ap_onboarding_status'] == null + ? null + : OnboardingStatusPayload.fromJson( + json['ap_onboarding_status'] as Map), $type: json['device_type'] as String?, ); @@ -83,7 +86,7 @@ Map _$$APModelImplToJson(_$APModelImpl instance) { writeNotNull('max_clients', instance.maxClients); writeNotNull('current_upload', instance.currentUpload); writeNotNull('current_download', instance.currentDownload); - writeNotNull('ap_onboarding_status', instance.onboardingStatus); + writeNotNull('ap_onboarding_status', instance.onboardingStatus?.toJson()); val['device_type'] = instance.$type; return val; } @@ -119,7 +122,10 @@ _$ONTModelImpl _$$ONTModelImplFromJson(Map json) => json['hn_counts'] as Map), isRegistered: json['is_registered'] as bool?, switchPort: json['switch_port'] as Map?, - onboardingStatus: json['ont_onboarding_status'] as Map?, + onboardingStatus: json['ont_onboarding_status'] == null + ? null + : OnboardingStatusPayload.fromJson( + json['ont_onboarding_status'] as Map), ports: (json['ont_ports'] as List?) ?.map((e) => e as Map) .toList(), @@ -158,7 +164,7 @@ Map _$$ONTModelImplToJson(_$ONTModelImpl instance) { writeNotNull('hn_counts', instance.hnCounts?.toJson()); writeNotNull('is_registered', instance.isRegistered); writeNotNull('switch_port', instance.switchPort); - writeNotNull('ont_onboarding_status', instance.onboardingStatus); + writeNotNull('ont_onboarding_status', instance.onboardingStatus?.toJson()); writeNotNull('ont_ports', instance.ports); writeNotNull('uptime', instance.uptime); writeNotNull('phase', instance.phase); diff --git a/lib/features/devices/presentation/screens/device_detail_screen.dart b/lib/features/devices/presentation/screens/device_detail_screen.dart index bb78e0c..c382fe6 100644 --- a/lib/features/devices/presentation/screens/device_detail_screen.dart +++ b/lib/features/devices/presentation/screens/device_detail_screen.dart @@ -476,6 +476,9 @@ class _OverviewTabState extends ConsumerState<_OverviewTab> ), const SizedBox(height: 16), + // Onboarding Status Card (for AP/ONT devices) + OnboardingStatusCard(deviceId: widget.device.id), + // Device detail sections DeviceDetailSections( device: widget.device, diff --git a/lib/features/onboarding/presentation/providers/device_onboarding_provider.g.dart b/lib/features/onboarding/presentation/providers/device_onboarding_provider.g.dart index a061b53..0475b52 100644 --- a/lib/features/onboarding/presentation/providers/device_onboarding_provider.g.dart +++ b/lib/features/onboarding/presentation/providers/device_onboarding_provider.g.dart @@ -42,7 +42,11 @@ final messageResolverProvider = AutoDisposeProvider.internal( typedef MessageResolverRef = AutoDisposeProviderRef; String _$deviceOnboardingStateHash() => +<<<<<<< HEAD r'a6bb3f939d79c780098bca9aecae84a7b9d83e86'; +======= + r'78fa397037352d508c551bff635a9650bdc6ca6b'; +>>>>>>> 6a559fa (Draft for device onboarding) /// Copied from Dart SDK class _SystemHash { diff --git a/lib/features/onboarding/presentation/widgets/onboarding_status_card.dart b/lib/features/onboarding/presentation/widgets/onboarding_status_card.dart index 60d39d8..c1f06eb 100644 --- a/lib/features/onboarding/presentation/widgets/onboarding_status_card.dart +++ b/lib/features/onboarding/presentation/widgets/onboarding_status_card.dart @@ -46,7 +46,10 @@ class OnboardingStatusCardContent extends StatelessWidget { required this.title, required this.resolution, this.onTap, +<<<<<<< HEAD this.showDivider = true, +======= +>>>>>>> 6a559fa (Draft for device onboarding) super.key, }); @@ -54,6 +57,7 @@ class OnboardingStatusCardContent extends StatelessWidget { final String title; final String resolution; final VoidCallback? onTap; +<<<<<<< HEAD final bool showDivider; @override @@ -157,6 +161,76 @@ class OnboardingStatusCardContent extends StatelessWidget { ), ), ], +======= + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title header (orange text) + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.orange, + ), + ), + + const SizedBox(height: 12), + + // Error box (if error exists) + if (state.errorText != null && state.errorText!.isNotEmpty) + _buildErrorBox(context), + + if (state.errorText != null && state.errorText!.isNotEmpty) + const SizedBox(height: 12), + + // Stage indicator row + _buildStageRow(context), + + const SizedBox(height: 16), + + // Stage progress circles + _buildStageCircles(context), + + const SizedBox(height: 16), + + // Resolution text + Text( + resolution, + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + ), + ), + ], + ), + ), + ), + ), +>>>>>>> 6a559fa (Draft for device onboarding) ); } @@ -191,23 +265,79 @@ class OnboardingStatusCardContent extends StatelessWidget { ); } +<<<<<<< HEAD +======= + Widget _buildStageRow(BuildContext context) { + final elapsedText = state.elapsedTimeFormatted; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Stage indicator + Text( + 'Stage ${state.currentStage}/${state.maxStages}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.orange, + ), + ), + + // Elapsed time + if (elapsedText != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + elapsedText, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.orange, + ), + ), + ), + ], + ); + } + +>>>>>>> 6a559fa (Draft for device onboarding) Widget _buildStageCircles(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: List.generate(state.maxStages, (index) { final stageNumber = index + 1; +<<<<<<< HEAD final isCompleted = stageNumber <= state.currentStage; final isCurrent = stageNumber == state.currentStage; final isComplete = state.isComplete; +======= + final isCompleted = stageNumber < state.currentStage; + final isCurrent = stageNumber == state.currentStage; + final isComplete = state.isComplete; + + // If onboarding is complete, all stages show as completed +>>>>>>> 6a559fa (Draft for device onboarding) if (isComplete) { return _buildCompletedCircle(); } +<<<<<<< HEAD +======= + // Current stage or completed stages +>>>>>>> 6a559fa (Draft for device onboarding) if (isCompleted || (isCurrent && state.isComplete)) { return _buildCompletedCircle(); } +<<<<<<< HEAD +======= + // Pending stages (including current if not complete) +>>>>>>> 6a559fa (Draft for device onboarding) return _buildPendingCircle(); }), ); diff --git a/lib/features/rooms/presentation/screens/room_detail_screen.dart b/lib/features/rooms/presentation/screens/room_detail_screen.dart index 5759d7c..26b0b73 100644 --- a/lib/features/rooms/presentation/screens/room_detail_screen.dart +++ b/lib/features/rooms/presentation/screens/room_detail_screen.dart @@ -9,9 +9,13 @@ import 'package:rgnets_fdk/core/widgets/widgets.dart'; import 'package:rgnets_fdk/features/devices/domain/constants/device_types.dart'; import 'package:rgnets_fdk/features/devices/presentation/providers/devices_provider.dart'; <<<<<<< HEAD +<<<<<<< HEAD import 'package:rgnets_fdk/features/onboarding/presentation/widgets/onboarding_stage_badge.dart'; ======= >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= +import 'package:rgnets_fdk/features/onboarding/presentation/widgets/onboarding_stage_badge.dart'; +>>>>>>> 6a559fa (Draft for device onboarding) import 'package:rgnets_fdk/features/room_readiness/domain/entities/room_readiness.dart'; import 'package:rgnets_fdk/features/rooms/presentation/providers/room_device_view_model.dart'; import 'package:rgnets_fdk/features/rooms/presentation/providers/room_view_models.dart'; diff --git a/lib/main.dart b/lib/main.dart index d4796ba..c4c3fb9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,10 @@ import 'package:rgnets_fdk/features/auth/presentation/providers/auth_notifier.da import 'package:rgnets_fdk/features/auth/presentation/widgets/credential_approval_sheet.dart'; import 'package:rgnets_fdk/features/initialization/initialization.dart'; import 'package:rgnets_fdk/features/onboarding/data/config/onboarding_config.dart'; +<<<<<<< HEAD import 'package:sentry_flutter/sentry_flutter.dart'; +======= +>>>>>>> 6a559fa (Draft for device onboarding) import 'package:shared_preferences/shared_preferences.dart'; void _configureImageCache() { @@ -78,6 +81,7 @@ void main() async { } }; +<<<<<<< HEAD // Initialize providers with error handling late final SharedPreferences sharedPreferences; try { @@ -107,6 +111,24 @@ void main() async { }, (error, stackTrace) async { await ErrorReporter.report(error, stackTrace: stackTrace); }); +======= + // Initialize onboarding configuration + try { + await OnboardingConfig.initialize(); + } on Exception catch (e) { + debugPrint('Failed to initialize OnboardingConfig: $e'); + // Non-fatal - app can continue without onboarding UI + } + + runApp( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(sharedPreferences), + ], + child: const FDKApp(), + ), + ); +>>>>>>> 6a559fa (Draft for device onboarding) } class FDKApp extends ConsumerStatefulWidget { diff --git a/pubspec.yaml b/pubspec.yaml index 997381a..1403c12 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -123,6 +123,7 @@ flutter: # MAC address database for OUI lookup - assets/mac_unified.csv +<<<<<<< HEAD <<<<<<< HEAD # Configuration files - assets/config/onboarding_messages.json @@ -130,6 +131,10 @@ flutter: # Speed test indicator images - assets/speed_test_indicator_img/ >>>>>>> 24906fa (Add pms speed test) +======= + # Configuration files + - assets/config/onboarding_messages.json +>>>>>>> 6a559fa (Draft for device onboarding) # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images From 6432e3085ff4e3152872379b46471557c94b6d25 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Thu, 22 Jan 2026 13:57:39 -0800 Subject: [PATCH 17/24] Pass in onboarding state through websocket --- .../screens/device_detail_screen.dart | 11 +- .../widgets/onboarding_status_card.dart | 133 +++++++++++------- 2 files changed, 94 insertions(+), 50 deletions(-) diff --git a/lib/features/devices/presentation/screens/device_detail_screen.dart b/lib/features/devices/presentation/screens/device_detail_screen.dart index c382fe6..c68ce3d 100644 --- a/lib/features/devices/presentation/screens/device_detail_screen.dart +++ b/lib/features/devices/presentation/screens/device_detail_screen.dart @@ -468,6 +468,7 @@ class _OverviewTabState extends ConsumerState<_OverviewTab> children: [ // Unified Summary section at the top UnifiedSummaryCardContent(device: widget.device), +<<<<<<< HEAD // Onboarding Status section (for AP/ONT devices) OnboardingStatusCard(deviceId: widget.device.id), @@ -475,9 +476,15 @@ class _OverviewTabState extends ConsumerState<_OverviewTab> ), ), const SizedBox(height: 16), +======= +>>>>>>> 0a05c3e (Pass in onboarding state through websocket) - // Onboarding Status Card (for AP/ONT devices) - OnboardingStatusCard(deviceId: widget.device.id), + // Onboarding Status section (for AP/ONT devices) + OnboardingStatusCard(deviceId: widget.device.id), + ], + ), + ), + const SizedBox(height: 16), // Device detail sections DeviceDetailSections( diff --git a/lib/features/onboarding/presentation/widgets/onboarding_status_card.dart b/lib/features/onboarding/presentation/widgets/onboarding_status_card.dart index c1f06eb..22ac188 100644 --- a/lib/features/onboarding/presentation/widgets/onboarding_status_card.dart +++ b/lib/features/onboarding/presentation/widgets/onboarding_status_card.dart @@ -46,10 +46,14 @@ class OnboardingStatusCardContent extends StatelessWidget { required this.title, required this.resolution, this.onTap, +<<<<<<< HEAD <<<<<<< HEAD this.showDivider = true, ======= >>>>>>> 6a559fa (Draft for device onboarding) +======= + this.showDivider = true, +>>>>>>> 0a05c3e (Pass in onboarding state through websocket) super.key, }); @@ -57,6 +61,7 @@ class OnboardingStatusCardContent extends StatelessWidget { final String title; final String resolution; final VoidCallback? onTap; +<<<<<<< HEAD <<<<<<< HEAD final bool showDivider; @@ -162,75 +167,104 @@ class OnboardingStatusCardContent extends StatelessWidget { ), ], ======= +======= + final bool showDivider; +>>>>>>> 0a05c3e (Pass in onboarding state through websocket) @override Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), + final isComplete = state.isComplete; + + // Use orange/warning styling when not complete + final titleColor = isComplete ? Colors.black87 : Colors.white; + final stageColor = isComplete ? Colors.green : Colors.orange; + final titleBgColor = isComplete ? null : Colors.orange; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Divider line at top (matches design) - only for complete state + if (showDivider && isComplete) + Divider( + color: Colors.grey[300], + thickness: 1, + height: 1, ), - ], - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title header (orange text) + + // Title header with background when not complete + if (!isComplete) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: titleBgColor, + child: Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: titleColor, + ), + ), + ), + + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Error box (if error exists) - show at top + if (state.errorText != null && state.errorText!.isNotEmpty) ...[ + _buildErrorBox(context), + const SizedBox(height: 12), + ], + + // Title header (black text, bold) - only for complete state + if (isComplete) Text( title, - style: const TextStyle( + style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, - color: Colors.orange, + color: titleColor, ), ), - const SizedBox(height: 12), - - // Error box (if error exists) - if (state.errorText != null && state.errorText!.isNotEmpty) - _buildErrorBox(context), - - if (state.errorText != null && state.errorText!.isNotEmpty) - const SizedBox(height: 12), + if (isComplete) const SizedBox(height: 4), - // Stage indicator row - _buildStageRow(context), + // Stage indicator + Text( + 'Stage ${state.currentStage}/${state.maxStages}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: stageColor, + ), + ), - const SizedBox(height: 16), + const SizedBox(height: 16), - // Stage progress circles - _buildStageCircles(context), + // Stage progress circles + _buildStageCircles(context), - const SizedBox(height: 16), + const SizedBox(height: 16), - // Resolution text - Text( - resolution, - style: TextStyle( - fontSize: 14, - color: Colors.grey[700], - ), + // Resolution text + Text( + resolution, + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], ), - ], - ), + ), + ], ), ), +<<<<<<< HEAD ), >>>>>>> 6a559fa (Draft for device onboarding) +======= + ], +>>>>>>> 0a05c3e (Pass in onboarding state through websocket) ); } @@ -265,6 +299,7 @@ class OnboardingStatusCardContent extends StatelessWidget { ); } +<<<<<<< HEAD <<<<<<< HEAD ======= Widget _buildStageRow(BuildContext context) { @@ -305,6 +340,8 @@ class OnboardingStatusCardContent extends StatelessWidget { } >>>>>>> 6a559fa (Draft for device onboarding) +======= +>>>>>>> 0a05c3e (Pass in onboarding state through websocket) Widget _buildStageCircles(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, From 577a480a4648583725a22c6b7a89e688fb06ebe5 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Fri, 23 Jan 2026 00:25:57 -0800 Subject: [PATCH 18/24] Fix ui bug and set up websockets for updating notes --- .../providers/devices_provider.g.dart | 4 ++++ .../screens/device_detail_screen.dart | 3 +++ .../presentation/screens/devices_screen.dart | 6 +++++ .../screens/note_edit_screen.dart | 24 +++++++++++++++++++ .../widgets/device_detail_sections.dart | 8 +++++++ .../providers/room_view_models.dart | 10 ++++++++ .../providers/room_view_models.g.dart | 4 ++++ .../presentation/screens/rooms_screen.dart | 4 ++++ 8 files changed, 63 insertions(+) diff --git a/lib/features/devices/presentation/providers/devices_provider.g.dart b/lib/features/devices/presentation/providers/devices_provider.g.dart index 7c2ac39..7d7610e 100644 --- a/lib/features/devices/presentation/providers/devices_provider.g.dart +++ b/lib/features/devices/presentation/providers/devices_provider.g.dart @@ -37,7 +37,11 @@ final devicesNotifierProvider = ); typedef _$DevicesNotifier = AsyncNotifier>; +<<<<<<< HEAD String _$deviceNotifierHash() => r'46818e2910dc06852fcac29e0ef4473d423c34f0'; +======= +String _$deviceNotifierHash() => r'6575f26908f09f5a5bae476dedfe95eac4e2567a'; +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) /// Copied from Dart SDK class _SystemHash { diff --git a/lib/features/devices/presentation/screens/device_detail_screen.dart b/lib/features/devices/presentation/screens/device_detail_screen.dart index c68ce3d..d85e397 100644 --- a/lib/features/devices/presentation/screens/device_detail_screen.dart +++ b/lib/features/devices/presentation/screens/device_detail_screen.dart @@ -388,11 +388,14 @@ class _OverviewTabState extends ConsumerState<_OverviewTab> } } +<<<<<<< HEAD void _handleUploadComplete() { // Refresh device data to show newly uploaded images ref.read(deviceNotifierProvider(widget.device.id).notifier).refresh(); } +======= +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) Future _handleSaveNote(String note) async { final success = await ref .read(deviceNotifierProvider(widget.device.id).notifier) diff --git a/lib/features/devices/presentation/screens/devices_screen.dart b/lib/features/devices/presentation/screens/devices_screen.dart index 1952ff1..4d802e8 100644 --- a/lib/features/devices/presentation/screens/devices_screen.dart +++ b/lib/features/devices/presentation/screens/devices_screen.dart @@ -292,6 +292,9 @@ class _DevicesScreenState extends ConsumerState { ), ), <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) // Search bar SearchBarWidget( @@ -305,9 +308,12 @@ class _DevicesScreenState extends ConsumerState { }, ), +<<<<<<< HEAD ======= >>>>>>> 81c4e9a (Add deployment phase filtering (#13)) +======= +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) // Phase filter bar _buildPhaseFilterBar(ref, devices), diff --git a/lib/features/devices/presentation/screens/note_edit_screen.dart b/lib/features/devices/presentation/screens/note_edit_screen.dart index 0165f30..34ce508 100644 --- a/lib/features/devices/presentation/screens/note_edit_screen.dart +++ b/lib/features/devices/presentation/screens/note_edit_screen.dart @@ -63,6 +63,30 @@ class _NoteEditScreenState extends State { icon: const Icon(Icons.close), onPressed: _cancel, ), +<<<<<<< HEAD +======= + actions: [ + TextButton( + onPressed: _isSaving ? null : _saveNote, + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Save', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ], +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) ), resizeToAvoidBottomInset: true, body: SafeArea( diff --git a/lib/features/devices/presentation/widgets/device_detail_sections.dart b/lib/features/devices/presentation/widgets/device_detail_sections.dart index 75b9b48..2b8bb48 100644 --- a/lib/features/devices/presentation/widgets/device_detail_sections.dart +++ b/lib/features/devices/presentation/widgets/device_detail_sections.dart @@ -39,9 +39,13 @@ class DeviceDetailSections extends ConsumerWidget { const SizedBox(height: 16), _buildTrafficSection(context), const SizedBox(height: 16), +<<<<<<< HEAD _buildSystemSection(context), const SizedBox(height: 16), _buildImagesSection(context, ref), +======= + _buildImagesSection(context), +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) ], ); } @@ -186,6 +190,7 @@ class DeviceDetailSections extends ConsumerWidget { ); } +<<<<<<< HEAD Widget _buildSystemSection(BuildContext context) { if (device.model == null && device.serialNumber == null && @@ -211,6 +216,9 @@ class DeviceDetailSections extends ConsumerWidget { } /// Filter to only valid HTTP/HTTPS image URLs (for display) +======= + /// Filter to only valid HTTP/HTTPS image URLs +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) List get _validImages { final images = device.images; if (images == null || images.isEmpty) { diff --git a/lib/features/rooms/presentation/providers/room_view_models.dart b/lib/features/rooms/presentation/providers/room_view_models.dart index 0ce1f32..e016e00 100644 --- a/lib/features/rooms/presentation/providers/room_view_models.dart +++ b/lib/features/rooms/presentation/providers/room_view_models.dart @@ -4,9 +4,13 @@ import 'package:rgnets_fdk/features/devices/presentation/providers/devices_provi import 'package:rgnets_fdk/features/room_readiness/domain/entities/room_readiness.dart'; import 'package:rgnets_fdk/features/room_readiness/presentation/providers/room_readiness_provider.dart'; <<<<<<< HEAD +<<<<<<< HEAD import 'package:rgnets_fdk/features/rooms/presentation/providers/room_ui_state_provider.dart'; ======= >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= +import 'package:rgnets_fdk/features/rooms/presentation/providers/room_ui_state_provider.dart'; +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) import 'package:rgnets_fdk/features/rooms/presentation/providers/rooms_riverpod_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -165,6 +169,9 @@ List filteredRoomViewModels( } <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) // Apply search filter if (searchQuery.isNotEmpty) { filtered = filtered.where((vm) { @@ -173,8 +180,11 @@ List filteredRoomViewModels( }).toList(); } +<<<<<<< HEAD ======= >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) // Sort by room name alphabetically filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); diff --git a/lib/features/rooms/presentation/providers/room_view_models.g.dart b/lib/features/rooms/presentation/providers/room_view_models.g.dart index 4338250..f8820c2 100644 --- a/lib/features/rooms/presentation/providers/room_view_models.g.dart +++ b/lib/features/rooms/presentation/providers/room_view_models.g.dart @@ -187,11 +187,15 @@ class _RoomViewModelByIdProviderElement } String _$filteredRoomViewModelsHash() => +<<<<<<< HEAD <<<<<<< HEAD r'6fe8852fd0d485183dde0dee1d150dba626a92bc'; ======= r'c4f8d9f36ebbf2eea084e9f4a5d45494abf7819a'; >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= + r'6fe8852fd0d485183dde0dee1d150dba626a92bc'; +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) /// Provider for filtered room view models /// diff --git a/lib/features/rooms/presentation/screens/rooms_screen.dart b/lib/features/rooms/presentation/screens/rooms_screen.dart index 04d8aac..76fb918 100644 --- a/lib/features/rooms/presentation/screens/rooms_screen.dart +++ b/lib/features/rooms/presentation/screens/rooms_screen.dart @@ -7,9 +7,13 @@ import 'package:rgnets_fdk/core/widgets/unified_list/unified_list_item.dart'; import 'package:rgnets_fdk/core/widgets/widgets.dart'; import 'package:rgnets_fdk/features/room_readiness/domain/entities/room_readiness.dart'; <<<<<<< HEAD +<<<<<<< HEAD import 'package:rgnets_fdk/features/rooms/presentation/providers/room_ui_state_provider.dart'; ======= >>>>>>> da0b3f7 (Integrate room readiness status labels into Locations UI (#12)) +======= +import 'package:rgnets_fdk/features/rooms/presentation/providers/room_ui_state_provider.dart'; +>>>>>>> 7aa372b (Fix ui bug and set up websockets for updating notes) import 'package:rgnets_fdk/features/rooms/presentation/providers/room_view_models.dart'; import 'package:rgnets_fdk/features/rooms/presentation/providers/rooms_riverpod_provider.dart'; From ea36b5a43ce9129fbdc4b2cae3225ad67842de8d Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Fri, 16 Jan 2026 00:52:20 -0800 Subject: [PATCH 19/24] Attempt --- lib/features/devices/data/models/device_model_sealed.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/devices/data/models/device_model_sealed.dart b/lib/features/devices/data/models/device_model_sealed.dart index 0d26bd3..ef0aa45 100644 --- a/lib/features/devices/data/models/device_model_sealed.dart +++ b/lib/features/devices/data/models/device_model_sealed.dart @@ -1,7 +1,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:rgnets_fdk/features/devices/data/models/room_model.dart'; import 'package:rgnets_fdk/features/devices/domain/entities/device.dart'; -import 'package:rgnets_fdk/features/onboarding/data/models/onboarding_status_payload.dart'; // Assuming this path +import 'package:rgnets_fdk/features/onboarding/data/models/onboarding_status_payload.dart'; import 'package:rgnets_fdk/features/issues/data/models/health_counts_model.dart'; import 'package:rgnets_fdk/features/issues/data/models/health_notice_model.dart'; From 7ae30f971706c20d2312946536a0f33ba3b0f0a9 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Mon, 19 Jan 2026 18:04:51 -0800 Subject: [PATCH 20/24] Speed test implmentation with a service, does a join table for speed test and speed test results --- .../speed_test_websocket_data_source.dart | 110 +-- .../speed_test_repository_impl.dart | 78 ++ .../data/services/speed_test_service.dart | 33 +- .../domain/entities/speed_test_result.dart | 123 +-- .../entities/speed_test_result.freezed.dart | 527 ++++++------- .../domain/entities/speed_test_result.g.dart | 36 +- .../entities/speed_test_with_results.dart | 69 ++ .../speed_test_with_results.freezed.dart | 271 +++++++ .../repositories/speed_test_repository.dart | 12 + .../providers/speed_test_providers.dart | 262 ++----- .../providers/speed_test_providers.g.dart | 172 ++++- .../presentation/widgets/speed_test_card.dart | 313 +++++--- .../widgets/speed_test_popup.dart | 722 +++++++----------- 13 files changed, 1432 insertions(+), 1296 deletions(-) create mode 100644 lib/features/speed_test/domain/entities/speed_test_with_results.dart create mode 100644 lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart diff --git a/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart b/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart index 1fd1252..6aaf9f1 100644 --- a/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart +++ b/lib/features/speed_test/data/datasources/speed_test_websocket_data_source.dart @@ -34,10 +34,8 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { try { final response = await _webSocketService.requestActionCable( - action: 'resource_action', + action: 'index', resourceType: _speedTestConfigResourceType, - additionalData: {'crud_action': 'index'}, - timeout: const Duration(seconds: 15), ); final data = response.payload['data']; @@ -81,13 +79,9 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { } final response = await _webSocketService.requestActionCable( - action: 'resource_action', + action: 'show', resourceType: _speedTestConfigResourceType, - additionalData: { - 'crud_action': 'show', - 'id': id, - }, - timeout: const Duration(seconds: 15), + additionalData: {'id': id}, ); final data = response.payload['data']; @@ -121,9 +115,7 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { } try { - final additionalData = { - 'crud_action': 'index', - }; + final additionalData = {}; if (speedTestId != null) additionalData['speed_test_id'] = speedTestId; if (accessPointId != null) { additionalData['access_point_id'] = accessPointId; @@ -132,10 +124,9 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { if (offset != null) additionalData['offset'] = offset; final response = await _webSocketService.requestActionCable( - action: 'resource_action', + action: 'index', resourceType: _speedTestResultResourceType, - additionalData: additionalData, - timeout: const Duration(seconds: 15), + additionalData: additionalData.isNotEmpty ? additionalData : null, ); final data = response.payload['data']; @@ -183,13 +174,9 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { } final response = await _webSocketService.requestActionCable( - action: 'resource_action', + action: 'show', resourceType: _speedTestResultResourceType, - additionalData: { - 'crud_action': 'show', - 'id': id, - }, - timeout: const Duration(seconds: 15), + additionalData: {'id': id}, ); final data = response.payload['data']; @@ -210,47 +197,16 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { throw StateError('WebSocket not connected'); } - // Build params with only fields the backend accepts (same format as adhoc submission) - final params = { - if (result.speedTestId != null) 'speed_test_id': result.speedTestId, - if (result.downloadMbps != null) 'download_mbps': result.downloadMbps, - if (result.uploadMbps != null) 'upload_mbps': result.uploadMbps, - if (result.rtt != null) 'rtt': result.rtt, - if (result.jitter != null) 'jitter': result.jitter, - if (result.packetLoss != null) 'packet_loss': result.packetLoss, - 'passed': result.passed, - 'is_applicable': result.isApplicable, - if (result.initiatedAt != null) 'initiated_at': result.initiatedAt!.toIso8601String(), - if (result.completedAt != null) 'completed_at': result.completedAt!.toIso8601String(), - if (result.testType != null) 'test_type': result.testType, - if (result.source != null) 'source': result.source, - if (result.destination != null) 'destination': result.destination, - if (result.port != null) 'port': result.port, - if (result.iperfProtocol != null) 'iperf_protocol': result.iperfProtocol, - if (result.accessPointId != null) 'access_point_id': result.accessPointId, - if (result.testedViaAccessPointId != null) 'tested_via_access_point_id': result.testedViaAccessPointId, - if (result.testedViaAccessPointRadioId != null) 'tested_via_access_point_radio_id': result.testedViaAccessPointRadioId, - if (result.testedViaMediaConverterId != null) 'tested_via_media_converter_id': result.testedViaMediaConverterId, - if (result.uplinkId != null) 'uplink_id': result.uplinkId, - if (result.wlanId != null) 'wlan_id': result.wlanId, - if (result.pmsRoomId != null) 'pms_room_id': result.pmsRoomId, - if (result.roomType != null) 'room_type': result.roomType, - if (result.note != null) 'note': result.note, - if (result.raw != null) 'raw': result.raw, - }; - + final jsonToSend = result.toJson(); LoggerService.info( - 'createSpeedTestResult sending: $params', + 'createSpeedTestResult sending: $jsonToSend', tag: 'SpeedTestWS', ); final response = await _webSocketService.requestActionCable( - action: 'create_resource', + action: 'create', resourceType: _speedTestResultResourceType, - additionalData: { - 'params': params, - }, - timeout: const Duration(seconds: 15), + additionalData: jsonToSend, ); LoggerService.info( @@ -285,48 +241,10 @@ class SpeedTestWebSocketDataSource implements SpeedTestDataSource { throw ArgumentError('Cannot update speed test result without id'); } - // Build params with only fields the backend accepts - final params = { - if (result.speedTestId != null) 'speed_test_id': result.speedTestId, - if (result.downloadMbps != null) 'download_mbps': result.downloadMbps, - if (result.uploadMbps != null) 'upload_mbps': result.uploadMbps, - if (result.rtt != null) 'rtt': result.rtt, - if (result.jitter != null) 'jitter': result.jitter, - if (result.packetLoss != null) 'packet_loss': result.packetLoss, - 'passed': result.passed, - 'is_applicable': result.isApplicable, - if (result.initiatedAt != null) 'initiated_at': result.initiatedAt!.toIso8601String(), - if (result.completedAt != null) 'completed_at': result.completedAt!.toIso8601String(), - if (result.testType != null) 'test_type': result.testType, - if (result.source != null) 'source': result.source, - if (result.destination != null) 'destination': result.destination, - if (result.port != null) 'port': result.port, - if (result.iperfProtocol != null) 'iperf_protocol': result.iperfProtocol, - if (result.accessPointId != null) 'access_point_id': result.accessPointId, - if (result.testedViaAccessPointId != null) 'tested_via_access_point_id': result.testedViaAccessPointId, - if (result.testedViaAccessPointRadioId != null) 'tested_via_access_point_radio_id': result.testedViaAccessPointRadioId, - if (result.testedViaMediaConverterId != null) 'tested_via_media_converter_id': result.testedViaMediaConverterId, - if (result.uplinkId != null) 'uplink_id': result.uplinkId, - if (result.wlanId != null) 'wlan_id': result.wlanId, - if (result.pmsRoomId != null) 'pms_room_id': result.pmsRoomId, - if (result.roomType != null) 'room_type': result.roomType, - if (result.note != null) 'note': result.note, - if (result.raw != null) 'raw': result.raw, - }; - - LoggerService.info( - 'updateSpeedTestResult sending: $params', - tag: 'SpeedTestWS', - ); - final response = await _webSocketService.requestActionCable( - action: 'update_resource', + action: 'update', resourceType: _speedTestResultResourceType, - additionalData: { - 'id': result.id, - 'params': params, - }, - timeout: const Duration(seconds: 15), + additionalData: result.toJson(), ); final data = response.payload['data']; diff --git a/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart b/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart index 73f4756..60e58cf 100644 --- a/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart +++ b/lib/features/speed_test/data/repositories/speed_test_repository_impl.dart @@ -4,6 +4,7 @@ import 'package:rgnets_fdk/core/errors/failures.dart'; import 'package:rgnets_fdk/features/speed_test/data/datasources/speed_test_data_source.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; import 'package:rgnets_fdk/features/speed_test/domain/repositories/speed_test_repository.dart'; /// Implementation of [SpeedTestRepository] using WebSocket data source. @@ -123,6 +124,83 @@ class SpeedTestRepositoryImpl implements SpeedTestRepository { } } + // ============================================================================ + // Joined Operations + // ============================================================================ + + @override + Future> getSpeedTestWithResults( + int id, + ) async { + try { + _logger.i('SpeedTestRepositoryImpl: getSpeedTestWithResults($id) called'); + + // Fetch config and results in parallel + final configFuture = _dataSource.getSpeedTestConfig(id); + final resultsFuture = _dataSource.getSpeedTestResults(speedTestId: id); + + final config = await configFuture; + final results = await resultsFuture; + + final joined = SpeedTestWithResults( + config: config, + results: results, + ); + + _logger.i( + 'SpeedTestRepositoryImpl: Got config $id with ${results.length} results', + ); + return Right(joined); + } on Exception catch (e) { + _logger.e( + 'SpeedTestRepositoryImpl: Failed to get speed test with results: $e', + ); + return Left(_mapExceptionToFailure(e)); + } + } + + @override + Future>> + getAllSpeedTestsWithResults() async { + try { + _logger.i( + 'SpeedTestRepositoryImpl: getAllSpeedTestsWithResults() called', + ); + + // Fetch all configs and results + final configs = await _dataSource.getSpeedTestConfigs(); + final allResults = await _dataSource.getSpeedTestResults(); + + // Group results by speedTestId + final resultsByConfigId = >{}; + for (final result in allResults) { + if (result.speedTestId != null) { + resultsByConfigId + .putIfAbsent(result.speedTestId!, () => []) + .add(result); + } + } + + // Join configs with their results + final joined = configs.map((config) { + final results = config.id != null + ? (resultsByConfigId[config.id!] ?? []) + : []; + return SpeedTestWithResults(config: config, results: results); + }).toList(); + + _logger.i( + 'SpeedTestRepositoryImpl: Got ${joined.length} speed tests with results', + ); + return Right(joined); + } on Exception catch (e) { + _logger.e( + 'SpeedTestRepositoryImpl: Failed to get all speed tests with results: $e', + ); + return Left(_mapExceptionToFailure(e)); + } + } + // ============================================================================ // Helper Methods // ============================================================================ diff --git a/lib/features/speed_test/data/services/speed_test_service.dart b/lib/features/speed_test/data/services/speed_test_service.dart index 901c3ed..0e94fc4 100644 --- a/lib/features/speed_test/data/services/speed_test_service.dart +++ b/lib/features/speed_test/data/services/speed_test_service.dart @@ -11,13 +11,12 @@ import 'package:shared_preferences/shared_preferences.dart'; /// Main orchestrator service for speed testing class SpeedTestService { - /// Regular constructor - each notifier owns its own instance - SpeedTestService() - : _iperf3Service = Iperf3Service(), - _gatewayService = NetworkGatewayService(); + static final SpeedTestService _instance = SpeedTestService._internal(); + factory SpeedTestService() => _instance; + SpeedTestService._internal(); - final Iperf3Service _iperf3Service; - final NetworkGatewayService _gatewayService; + final Iperf3Service _iperf3Service = Iperf3Service(); + final NetworkGatewayService _gatewayService = NetworkGatewayService(); // Configuration String _serverHost = ''; @@ -132,15 +131,10 @@ class SpeedTestService { _statusMessageController.add(getMessage()); break; case 'completed': - // Don't set SpeedTestStatus.completed here - iperf3 sends 'completed' after - // each individual test (download/upload), but we want to wait until BOTH - // phases are done. The actual completion is handled in runSpeedTestWithFallback - // after both download and upload tests finish. - // Just update the message to show phase completion. - if (_isDownloadPhase) { - _statusMessageController.add('Download complete, starting upload...'); - } - // Don't set progress to 100% here either - that happens after the full test + _updateStatus(SpeedTestStatus.completed); + _statusMessageController.add(getMessage()); + _progress = 100.0; + _progressController.add(_progress); break; case 'cancelled': _updateStatus(SpeedTestStatus.idle); @@ -239,9 +233,6 @@ class SpeedTestService { _isRetryingFallback = true; // Enable fallback mode to suppress intermediate errors - // Capture test start time - final initiatedAt = DateTime.now(); - // Reset completed speeds from previous test _completedDownloadSpeed = 0.0; _completedUploadSpeed = 0.0; @@ -268,7 +259,7 @@ class SpeedTestService { try { // Attempt test with this server - final result = await _runTestWithServer(serverHost, localIp, initiatedAt); + final result = await _runTestWithServer(serverHost, localIp); if (result != null) { // Success! @@ -361,7 +352,7 @@ class SpeedTestService { /// Run test with a specific server, returns result or null if failed Future _runTestWithServer( - String serverHost, String? localIp, DateTime initiatedAt) async { + String serverHost, String? localIp) async { try { // Update the current server host being tested _serverHost = serverHost; @@ -425,7 +416,6 @@ class SpeedTestService { downloadMbps: (downloadSpeed as num).toDouble(), uploadMbps: (uploadSpeed as num).toDouble(), rtt: (latency as num).toDouble(), - initiatedAt: initiatedAt, completedAt: DateTime.now(), localIpAddress: localIp, serverHost: serverHost, @@ -572,7 +562,6 @@ class SpeedTestService { return const JsonCodec().encode(map); } - /// Ensure dispose method exists to clean up streams void dispose() { _progressSubscription?.cancel(); _statusController.close(); diff --git a/lib/features/speed_test/domain/entities/speed_test_result.dart b/lib/features/speed_test/domain/entities/speed_test_result.dart index 2f22b89..120c46f 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.dart @@ -4,66 +4,47 @@ import 'package:rgnets_fdk/core/services/logger_service.dart'; part 'speed_test_result.freezed.dart'; part 'speed_test_result.g.dart'; -/// Safely converts a value to int, handling strings and nulls -int? _toInt(dynamic value) { - if (value == null) return null; - if (value is int) return value; - if (value is double) return value.toInt(); - if (value is String) return int.tryParse(value); - return null; -} - -/// Safely converts a value to double, handling strings and nulls -double? _toDouble(dynamic value) { - if (value == null) return null; - if (value is double) return value; - if (value is int) return value.toDouble(); - if (value is String) return double.tryParse(value); - return null; -} - @freezed class SpeedTestResult with _$SpeedTestResult { const factory SpeedTestResult({ - @JsonKey(fromJson: _toInt) int? id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - @JsonKey(fromJson: _toInt) int? port, + int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? uploadMbps, - @JsonKey(fromJson: _toDouble) double? rtt, - @JsonKey(fromJson: _toDouble) double? jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? packetLoss, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, @Default(false) bool passed, @JsonKey(name: 'is_applicable') @Default(true) bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) - int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, + @JsonKey(name: 'admin_id') int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - // Legacy fields for backwards compatibility (not sent to server) - @JsonKey(includeToJson: false) @Default(false) bool hasError, - @JsonKey(name: 'error_message', includeToJson: false) String? errorMessage, + // Legacy fields for backwards compatibility + @Default(false) bool hasError, + String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost, }) = _SpeedTestResult; @@ -93,21 +74,16 @@ class SpeedTestResult with _$SpeedTestResult { /// Pre-process JSON to detect and correct swapped download/upload values static Map _preprocessJson(Map json) { - // First extract IDs from nested association objects - var normalizedJson = _extractNestedAssociationIds(json); - // Then normalize any string IDs to ints - normalizedJson = _normalizeTestedViaAccessPointId(normalizedJson); - - final download = _parseDecimal(normalizedJson['download_mbps']); - final upload = _parseDecimal(normalizedJson['upload_mbps']); + final download = _parseDecimal(json['download_mbps']); + final upload = _parseDecimal(json['upload_mbps']); if (download == null || upload == null) { - return normalizedJson; + return json; } // Both are 0 - likely incomplete test, don't swap if (download == 0 && upload == 0) { - return normalizedJson; + return json; } bool shouldSwap = false; @@ -133,63 +109,12 @@ class SpeedTestResult with _$SpeedTestResult { ); // Create a new map with swapped values return { - ...normalizedJson, + ...json, 'download_mbps': upload, 'upload_mbps': download, }; } - return normalizedJson; - } - - /// Extract IDs from nested association objects - /// RESTFramework sends associations as objects like: - /// "tested_via_access_point": { "id": 1309, "name": "..." } - /// instead of: - /// "tested_via_access_point_id": 1309 - static Map _extractNestedAssociationIds( - Map json, - ) { - final result = Map.from(json); - - // Map of association name to the corresponding _id field - const associationMappings = { - 'tested_via_access_point': 'tested_via_access_point_id', - 'tested_via_media_converter': 'tested_via_media_converter_id', - 'speed_test': 'speed_test_id', - 'pms_room': 'pms_room_id', - 'access_point': 'access_point_id', - }; - - for (final entry in associationMappings.entries) { - final associationKey = entry.key; - final idKey = entry.value; - - // Only extract if the _id field is not already set - if (result[idKey] == null && result[associationKey] is Map) { - final association = result[associationKey] as Map; - if (association['id'] != null) { - result[idKey] = association['id']; - } - } - } - - return result; - } - - static Map _normalizeTestedViaAccessPointId( - Map json, - ) { - final value = json['tested_via_access_point_id']; - if (value is String) { - final parsed = int.tryParse(value); - if (parsed != null) { - return { - ...json, - 'tested_via_access_point_id': parsed, - }; - } - } return json; } diff --git a/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart b/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart index 05b5940..2076854 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart @@ -20,27 +20,23 @@ SpeedTestResult _$SpeedTestResultFromJson(Map json) { /// @nodoc mixin _$SpeedTestResult { - @JsonKey(fromJson: _toInt) int? get id => throw _privateConstructorUsedError; - @JsonKey(name: 'speed_test_id', fromJson: _toInt) + @JsonKey(name: 'speed_test_id') int? get speedTestId => throw _privateConstructorUsedError; @JsonKey(name: 'test_type') String? get testType => throw _privateConstructorUsedError; String? get source => throw _privateConstructorUsedError; String? get destination => throw _privateConstructorUsedError; - @JsonKey(fromJson: _toInt) int? get port => throw _privateConstructorUsedError; @JsonKey(name: 'iperf_protocol') String? get iperfProtocol => throw _privateConstructorUsedError; - @JsonKey(name: 'download_mbps', fromJson: _toDouble) + @JsonKey(name: 'download_mbps') double? get downloadMbps => throw _privateConstructorUsedError; - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + @JsonKey(name: 'upload_mbps') double? get uploadMbps => throw _privateConstructorUsedError; - @JsonKey(fromJson: _toDouble) double? get rtt => throw _privateConstructorUsedError; - @JsonKey(fromJson: _toDouble) double? get jitter => throw _privateConstructorUsedError; - @JsonKey(name: 'packet_loss', fromJson: _toDouble) + @JsonKey(name: 'packet_loss') double? get packetLoss => throw _privateConstructorUsedError; bool get passed => throw _privateConstructorUsedError; @JsonKey(name: 'is_applicable') @@ -52,23 +48,23 @@ mixin _$SpeedTestResult { String? get raw => throw _privateConstructorUsedError; @JsonKey(name: 'image_url') String? get imageUrl => throw _privateConstructorUsedError; - @JsonKey(name: 'access_point_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? get accessPointId => throw _privateConstructorUsedError; - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_id') int? get testedViaAccessPointId => throw _privateConstructorUsedError; - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_radio_id') int? get testedViaAccessPointRadioId => throw _privateConstructorUsedError; - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? get testedViaMediaConverterId => throw _privateConstructorUsedError; - @JsonKey(name: 'uplink_id', fromJson: _toInt) + @JsonKey(name: 'uplink_id') int? get uplinkId => throw _privateConstructorUsedError; - @JsonKey(name: 'wlan_id', fromJson: _toInt) + @JsonKey(name: 'wlan_id') int? get wlanId => throw _privateConstructorUsedError; - @JsonKey(name: 'pms_room_id', fromJson: _toInt) + @JsonKey(name: 'pms_room_id') int? get pmsRoomId => throw _privateConstructorUsedError; @JsonKey(name: 'room_type') String? get roomType => throw _privateConstructorUsedError; - @JsonKey(name: 'admin_id', fromJson: _toInt) + @JsonKey(name: 'admin_id') int? get adminId => throw _privateConstructorUsedError; String? get note => throw _privateConstructorUsedError; String? get scratch => throw _privateConstructorUsedError; @@ -80,10 +76,8 @@ mixin _$SpeedTestResult { DateTime? get createdAt => throw _privateConstructorUsedError; @JsonKey(name: 'updated_at') DateTime? get updatedAt => - throw _privateConstructorUsedError; // Legacy fields for backwards compatibility (not sent to server) - @JsonKey(includeToJson: false) + throw _privateConstructorUsedError; // Legacy fields for backwards compatibility bool get hasError => throw _privateConstructorUsedError; - @JsonKey(name: 'error_message', includeToJson: false) String? get errorMessage => throw _privateConstructorUsedError; @JsonKey(name: 'local_ip_address') String? get localIpAddress => throw _privateConstructorUsedError; @@ -92,48 +86,43 @@ mixin _$SpeedTestResult { @optionalTypeArgs TResult when( TResult Function( - @JsonKey(fromJson: _toInt) int? id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - @JsonKey(fromJson: _toInt) int? port, + int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) - double? downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) - double? uploadMbps, - @JsonKey(fromJson: _toDouble) double? rtt, - @JsonKey(fromJson: _toDouble) double? jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) - double? packetLoss, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) - int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_radio_id') int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, + @JsonKey(name: 'admin_id') int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - @JsonKey(includeToJson: false) bool hasError, - @JsonKey(name: 'error_message', includeToJson: false) + bool hasError, String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost) @@ -143,48 +132,43 @@ mixin _$SpeedTestResult { @optionalTypeArgs TResult? whenOrNull( TResult? Function( - @JsonKey(fromJson: _toInt) int? id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - @JsonKey(fromJson: _toInt) int? port, + int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) - double? downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) - double? uploadMbps, - @JsonKey(fromJson: _toDouble) double? rtt, - @JsonKey(fromJson: _toDouble) double? jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) - double? packetLoss, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) - int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_radio_id') int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, + @JsonKey(name: 'admin_id') int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - @JsonKey(includeToJson: false) bool hasError, - @JsonKey(name: 'error_message', includeToJson: false) + bool hasError, String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost)? @@ -194,48 +178,43 @@ mixin _$SpeedTestResult { @optionalTypeArgs TResult maybeWhen( TResult Function( - @JsonKey(fromJson: _toInt) int? id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - @JsonKey(fromJson: _toInt) int? port, + int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) - double? downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) - double? uploadMbps, - @JsonKey(fromJson: _toDouble) double? rtt, - @JsonKey(fromJson: _toDouble) double? jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) - double? packetLoss, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) - int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_radio_id') int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, + @JsonKey(name: 'admin_id') int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - @JsonKey(includeToJson: false) bool hasError, - @JsonKey(name: 'error_message', includeToJson: false) + bool hasError, String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost)? @@ -272,44 +251,42 @@ abstract class $SpeedTestResultCopyWith<$Res> { _$SpeedTestResultCopyWithImpl<$Res, SpeedTestResult>; @useResult $Res call( - {@JsonKey(fromJson: _toInt) int? id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, + {int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - @JsonKey(fromJson: _toInt) int? port, + int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? uploadMbps, - @JsonKey(fromJson: _toDouble) double? rtt, - @JsonKey(fromJson: _toDouble) double? jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? packetLoss, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) - int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, + @JsonKey(name: 'admin_id') int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - @JsonKey(includeToJson: false) bool hasError, - @JsonKey(name: 'error_message', includeToJson: false) + bool hasError, String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost}); @@ -528,44 +505,42 @@ abstract class _$$SpeedTestResultImplCopyWith<$Res> @override @useResult $Res call( - {@JsonKey(fromJson: _toInt) int? id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, + {int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - @JsonKey(fromJson: _toInt) int? port, + int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? uploadMbps, - @JsonKey(fromJson: _toDouble) double? rtt, - @JsonKey(fromJson: _toDouble) double? jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? packetLoss, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) - int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, + @JsonKey(name: 'admin_id') int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - @JsonKey(includeToJson: false) bool hasError, - @JsonKey(name: 'error_message', includeToJson: false) + bool hasError, String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost}); @@ -777,44 +752,43 @@ class __$$SpeedTestResultImplCopyWithImpl<$Res> @JsonSerializable() class _$SpeedTestResultImpl extends _SpeedTestResult { const _$SpeedTestResultImpl( - {@JsonKey(fromJson: _toInt) this.id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) this.speedTestId, + {this.id, + @JsonKey(name: 'speed_test_id') this.speedTestId, @JsonKey(name: 'test_type') this.testType, this.source, this.destination, - @JsonKey(fromJson: _toInt) this.port, + this.port, @JsonKey(name: 'iperf_protocol') this.iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) this.downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) this.uploadMbps, - @JsonKey(fromJson: _toDouble) this.rtt, - @JsonKey(fromJson: _toDouble) this.jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) this.packetLoss, + @JsonKey(name: 'download_mbps') this.downloadMbps, + @JsonKey(name: 'upload_mbps') this.uploadMbps, + this.rtt, + this.jitter, + @JsonKey(name: 'packet_loss') this.packetLoss, this.passed = false, @JsonKey(name: 'is_applicable') this.isApplicable = true, @JsonKey(name: 'initiated_at') this.initiatedAt, @JsonKey(name: 'completed_at') this.completedAt, this.raw, @JsonKey(name: 'image_url') this.imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) this.accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) - this.testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') this.accessPointId, + @JsonKey(name: 'tested_via_access_point_id') this.testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') this.testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') this.testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) this.uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) this.wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) this.pmsRoomId, + @JsonKey(name: 'uplink_id') this.uplinkId, + @JsonKey(name: 'wlan_id') this.wlanId, + @JsonKey(name: 'pms_room_id') this.pmsRoomId, @JsonKey(name: 'room_type') this.roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) this.adminId, + @JsonKey(name: 'admin_id') this.adminId, this.note, this.scratch, @JsonKey(name: 'created_by') this.createdBy, @JsonKey(name: 'updated_by') this.updatedBy, @JsonKey(name: 'created_at') this.createdAt, @JsonKey(name: 'updated_at') this.updatedAt, - @JsonKey(includeToJson: false) this.hasError = false, - @JsonKey(name: 'error_message', includeToJson: false) this.errorMessage, + this.hasError = false, + this.errorMessage, @JsonKey(name: 'local_ip_address') this.localIpAddress, @JsonKey(name: 'server_host') this.serverHost}) : super._(); @@ -823,10 +797,9 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { _$$SpeedTestResultImplFromJson(json); @override - @JsonKey(fromJson: _toInt) final int? id; @override - @JsonKey(name: 'speed_test_id', fromJson: _toInt) + @JsonKey(name: 'speed_test_id') final int? speedTestId; @override @JsonKey(name: 'test_type') @@ -836,25 +809,22 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @override final String? destination; @override - @JsonKey(fromJson: _toInt) final int? port; @override @JsonKey(name: 'iperf_protocol') final String? iperfProtocol; @override - @JsonKey(name: 'download_mbps', fromJson: _toDouble) + @JsonKey(name: 'download_mbps') final double? downloadMbps; @override - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + @JsonKey(name: 'upload_mbps') final double? uploadMbps; @override - @JsonKey(fromJson: _toDouble) final double? rtt; @override - @JsonKey(fromJson: _toDouble) final double? jitter; @override - @JsonKey(name: 'packet_loss', fromJson: _toDouble) + @JsonKey(name: 'packet_loss') final double? packetLoss; @override @JsonKey() @@ -874,31 +844,31 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @JsonKey(name: 'image_url') final String? imageUrl; @override - @JsonKey(name: 'access_point_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') final int? accessPointId; @override - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_id') final int? testedViaAccessPointId; @override - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_radio_id') final int? testedViaAccessPointRadioId; @override - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') final int? testedViaMediaConverterId; @override - @JsonKey(name: 'uplink_id', fromJson: _toInt) + @JsonKey(name: 'uplink_id') final int? uplinkId; @override - @JsonKey(name: 'wlan_id', fromJson: _toInt) + @JsonKey(name: 'wlan_id') final int? wlanId; @override - @JsonKey(name: 'pms_room_id', fromJson: _toInt) + @JsonKey(name: 'pms_room_id') final int? pmsRoomId; @override @JsonKey(name: 'room_type') final String? roomType; @override - @JsonKey(name: 'admin_id', fromJson: _toInt) + @JsonKey(name: 'admin_id') final int? adminId; @override final String? note; @@ -916,12 +886,11 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @override @JsonKey(name: 'updated_at') final DateTime? updatedAt; -// Legacy fields for backwards compatibility (not sent to server) +// Legacy fields for backwards compatibility @override - @JsonKey(includeToJson: false) + @JsonKey() final bool hasError; @override - @JsonKey(name: 'error_message', includeToJson: false) final String? errorMessage; @override @JsonKey(name: 'local_ip_address') @@ -1062,48 +1031,43 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @optionalTypeArgs TResult when( TResult Function( - @JsonKey(fromJson: _toInt) int? id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - @JsonKey(fromJson: _toInt) int? port, + int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) - double? downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) - double? uploadMbps, - @JsonKey(fromJson: _toDouble) double? rtt, - @JsonKey(fromJson: _toDouble) double? jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) - double? packetLoss, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) - int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_radio_id') int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, + @JsonKey(name: 'admin_id') int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - @JsonKey(includeToJson: false) bool hasError, - @JsonKey(name: 'error_message', includeToJson: false) + bool hasError, String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost) @@ -1153,48 +1117,43 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @optionalTypeArgs TResult? whenOrNull( TResult? Function( - @JsonKey(fromJson: _toInt) int? id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - @JsonKey(fromJson: _toInt) int? port, + int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) - double? downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) - double? uploadMbps, - @JsonKey(fromJson: _toDouble) double? rtt, - @JsonKey(fromJson: _toDouble) double? jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) - double? packetLoss, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) - int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_radio_id') int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, + @JsonKey(name: 'admin_id') int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - @JsonKey(includeToJson: false) bool hasError, - @JsonKey(name: 'error_message', includeToJson: false) + bool hasError, String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost)? @@ -1244,48 +1203,43 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @optionalTypeArgs TResult maybeWhen( TResult Function( - @JsonKey(fromJson: _toInt) int? id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, + int? id, + @JsonKey(name: 'speed_test_id') int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - @JsonKey(fromJson: _toInt) int? port, + int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) - double? downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) - double? uploadMbps, - @JsonKey(fromJson: _toDouble) double? rtt, - @JsonKey(fromJson: _toDouble) double? jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) - double? packetLoss, + @JsonKey(name: 'download_mbps') double? downloadMbps, + @JsonKey(name: 'upload_mbps') double? uploadMbps, + double? rtt, + double? jitter, + @JsonKey(name: 'packet_loss') double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) - int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_radio_id') int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, + @JsonKey(name: 'uplink_id') int? uplinkId, + @JsonKey(name: 'wlan_id') int? wlanId, + @JsonKey(name: 'pms_room_id') int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, + @JsonKey(name: 'admin_id') int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @JsonKey(name: 'updated_by') String? updatedBy, @JsonKey(name: 'created_at') DateTime? createdAt, @JsonKey(name: 'updated_at') DateTime? updatedAt, - @JsonKey(includeToJson: false) bool hasError, - @JsonKey(name: 'error_message', includeToJson: false) + bool hasError, String? errorMessage, @JsonKey(name: 'local_ip_address') String? localIpAddress, @JsonKey(name: 'server_host') String? serverHost)? @@ -1373,62 +1327,56 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { abstract class _SpeedTestResult extends SpeedTestResult { const factory _SpeedTestResult( - {@JsonKey(fromJson: _toInt) final int? id, - @JsonKey(name: 'speed_test_id', fromJson: _toInt) final int? speedTestId, - @JsonKey(name: 'test_type') final String? testType, - final String? source, - final String? destination, - @JsonKey(fromJson: _toInt) final int? port, - @JsonKey(name: 'iperf_protocol') final String? iperfProtocol, - @JsonKey(name: 'download_mbps', fromJson: _toDouble) - final double? downloadMbps, - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) - final double? uploadMbps, - @JsonKey(fromJson: _toDouble) final double? rtt, - @JsonKey(fromJson: _toDouble) final double? jitter, - @JsonKey(name: 'packet_loss', fromJson: _toDouble) - final double? packetLoss, - final bool passed, - @JsonKey(name: 'is_applicable') final bool isApplicable, - @JsonKey(name: 'initiated_at') final DateTime? initiatedAt, - @JsonKey(name: 'completed_at') final DateTime? completedAt, - final String? raw, - @JsonKey(name: 'image_url') final String? imageUrl, - @JsonKey(name: 'access_point_id', fromJson: _toInt) - final int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) - final int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) - final int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) - final int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id', fromJson: _toInt) final int? uplinkId, - @JsonKey(name: 'wlan_id', fromJson: _toInt) final int? wlanId, - @JsonKey(name: 'pms_room_id', fromJson: _toInt) final int? pmsRoomId, - @JsonKey(name: 'room_type') final String? roomType, - @JsonKey(name: 'admin_id', fromJson: _toInt) final int? adminId, - final String? note, - final String? scratch, - @JsonKey(name: 'created_by') final String? createdBy, - @JsonKey(name: 'updated_by') final String? updatedBy, - @JsonKey(name: 'created_at') final DateTime? createdAt, - @JsonKey(name: 'updated_at') final DateTime? updatedAt, - @JsonKey(includeToJson: false) final bool hasError, - @JsonKey(name: 'error_message', includeToJson: false) - final String? errorMessage, - @JsonKey(name: 'local_ip_address') final String? localIpAddress, - @JsonKey(name: 'server_host') - final String? serverHost}) = _$SpeedTestResultImpl; + {final int? id, + @JsonKey(name: 'speed_test_id') final int? speedTestId, + @JsonKey(name: 'test_type') final String? testType, + final String? source, + final String? destination, + final int? port, + @JsonKey(name: 'iperf_protocol') final String? iperfProtocol, + @JsonKey(name: 'download_mbps') final double? downloadMbps, + @JsonKey(name: 'upload_mbps') final double? uploadMbps, + final double? rtt, + final double? jitter, + @JsonKey(name: 'packet_loss') final double? packetLoss, + final bool passed, + @JsonKey(name: 'is_applicable') final bool isApplicable, + @JsonKey(name: 'initiated_at') final DateTime? initiatedAt, + @JsonKey(name: 'completed_at') final DateTime? completedAt, + final String? raw, + @JsonKey(name: 'image_url') final String? imageUrl, + @JsonKey(name: 'access_point_id') final int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id') + final int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id') + final int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id') + final int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id') final int? uplinkId, + @JsonKey(name: 'wlan_id') final int? wlanId, + @JsonKey(name: 'pms_room_id') final int? pmsRoomId, + @JsonKey(name: 'room_type') final String? roomType, + @JsonKey(name: 'admin_id') final int? adminId, + final String? note, + final String? scratch, + @JsonKey(name: 'created_by') final String? createdBy, + @JsonKey(name: 'updated_by') final String? updatedBy, + @JsonKey(name: 'created_at') final DateTime? createdAt, + @JsonKey(name: 'updated_at') final DateTime? updatedAt, + final bool hasError, + final String? errorMessage, + @JsonKey(name: 'local_ip_address') final String? localIpAddress, + @JsonKey(name: 'server_host') final String? serverHost}) = + _$SpeedTestResultImpl; const _SpeedTestResult._() : super._(); factory _SpeedTestResult.fromJson(Map json) = _$SpeedTestResultImpl.fromJson; @override - @JsonKey(fromJson: _toInt) int? get id; @override - @JsonKey(name: 'speed_test_id', fromJson: _toInt) + @JsonKey(name: 'speed_test_id') int? get speedTestId; @override @JsonKey(name: 'test_type') @@ -1438,25 +1386,22 @@ abstract class _SpeedTestResult extends SpeedTestResult { @override String? get destination; @override - @JsonKey(fromJson: _toInt) int? get port; @override @JsonKey(name: 'iperf_protocol') String? get iperfProtocol; @override - @JsonKey(name: 'download_mbps', fromJson: _toDouble) + @JsonKey(name: 'download_mbps') double? get downloadMbps; @override - @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + @JsonKey(name: 'upload_mbps') double? get uploadMbps; @override - @JsonKey(fromJson: _toDouble) double? get rtt; @override - @JsonKey(fromJson: _toDouble) double? get jitter; @override - @JsonKey(name: 'packet_loss', fromJson: _toDouble) + @JsonKey(name: 'packet_loss') double? get packetLoss; @override bool get passed; @@ -1475,31 +1420,31 @@ abstract class _SpeedTestResult extends SpeedTestResult { @JsonKey(name: 'image_url') String? get imageUrl; @override - @JsonKey(name: 'access_point_id', fromJson: _toInt) + @JsonKey(name: 'access_point_id') int? get accessPointId; @override - @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_id') int? get testedViaAccessPointId; @override - @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_access_point_radio_id') int? get testedViaAccessPointRadioId; @override - @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + @JsonKey(name: 'tested_via_media_converter_id') int? get testedViaMediaConverterId; @override - @JsonKey(name: 'uplink_id', fromJson: _toInt) + @JsonKey(name: 'uplink_id') int? get uplinkId; @override - @JsonKey(name: 'wlan_id', fromJson: _toInt) + @JsonKey(name: 'wlan_id') int? get wlanId; @override - @JsonKey(name: 'pms_room_id', fromJson: _toInt) + @JsonKey(name: 'pms_room_id') int? get pmsRoomId; @override @JsonKey(name: 'room_type') String? get roomType; @override - @JsonKey(name: 'admin_id', fromJson: _toInt) + @JsonKey(name: 'admin_id') int? get adminId; @override String? get note; @@ -1517,11 +1462,9 @@ abstract class _SpeedTestResult extends SpeedTestResult { @override @JsonKey(name: 'updated_at') DateTime? get updatedAt; - @override // Legacy fields for backwards compatibility (not sent to server) - @JsonKey(includeToJson: false) + @override // Legacy fields for backwards compatibility bool get hasError; @override - @JsonKey(name: 'error_message', includeToJson: false) String? get errorMessage; @override @JsonKey(name: 'local_ip_address') diff --git a/lib/features/speed_test/domain/entities/speed_test_result.g.dart b/lib/features/speed_test/domain/entities/speed_test_result.g.dart index 3bb6318..b75fb75 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.g.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.g.dart @@ -9,18 +9,18 @@ part of 'speed_test_result.dart'; _$SpeedTestResultImpl _$$SpeedTestResultImplFromJson( Map json) => _$SpeedTestResultImpl( - id: _toInt(json['id']), - speedTestId: _toInt(json['speed_test_id']), + id: (json['id'] as num?)?.toInt(), + speedTestId: (json['speed_test_id'] as num?)?.toInt(), testType: json['test_type'] as String?, source: json['source'] as String?, destination: json['destination'] as String?, - port: _toInt(json['port']), + port: (json['port'] as num?)?.toInt(), iperfProtocol: json['iperf_protocol'] as String?, - downloadMbps: _toDouble(json['download_mbps']), - uploadMbps: _toDouble(json['upload_mbps']), - rtt: _toDouble(json['rtt']), - jitter: _toDouble(json['jitter']), - packetLoss: _toDouble(json['packet_loss']), + downloadMbps: (json['download_mbps'] as num?)?.toDouble(), + uploadMbps: (json['upload_mbps'] as num?)?.toDouble(), + rtt: (json['rtt'] as num?)?.toDouble(), + jitter: (json['jitter'] as num?)?.toDouble(), + packetLoss: (json['packet_loss'] as num?)?.toDouble(), passed: json['passed'] as bool? ?? false, isApplicable: json['is_applicable'] as bool? ?? true, initiatedAt: json['initiated_at'] == null @@ -31,16 +31,18 @@ _$SpeedTestResultImpl _$$SpeedTestResultImplFromJson( : DateTime.parse(json['completed_at'] as String), raw: json['raw'] as String?, imageUrl: json['image_url'] as String?, - accessPointId: _toInt(json['access_point_id']), - testedViaAccessPointId: _toInt(json['tested_via_access_point_id']), + accessPointId: (json['access_point_id'] as num?)?.toInt(), + testedViaAccessPointId: + (json['tested_via_access_point_id'] as num?)?.toInt(), testedViaAccessPointRadioId: - _toInt(json['tested_via_access_point_radio_id']), - testedViaMediaConverterId: _toInt(json['tested_via_media_converter_id']), - uplinkId: _toInt(json['uplink_id']), - wlanId: _toInt(json['wlan_id']), - pmsRoomId: _toInt(json['pms_room_id']), + (json['tested_via_access_point_radio_id'] as num?)?.toInt(), + testedViaMediaConverterId: + (json['tested_via_media_converter_id'] as num?)?.toInt(), + uplinkId: (json['uplink_id'] as num?)?.toInt(), + wlanId: (json['wlan_id'] as num?)?.toInt(), + pmsRoomId: (json['pms_room_id'] as num?)?.toInt(), roomType: json['room_type'] as String?, - adminId: _toInt(json['admin_id']), + adminId: (json['admin_id'] as num?)?.toInt(), note: json['note'] as String?, scratch: json['scratch'] as String?, createdBy: json['created_by'] as String?, @@ -102,6 +104,8 @@ Map _$$SpeedTestResultImplToJson( writeNotNull('updated_by', instance.updatedBy); writeNotNull('created_at', instance.createdAt?.toIso8601String()); writeNotNull('updated_at', instance.updatedAt?.toIso8601String()); + val['has_error'] = instance.hasError; + writeNotNull('error_message', instance.errorMessage); writeNotNull('local_ip_address', instance.localIpAddress); writeNotNull('server_host', instance.serverHost); return val; diff --git a/lib/features/speed_test/domain/entities/speed_test_with_results.dart b/lib/features/speed_test/domain/entities/speed_test_with_results.dart new file mode 100644 index 0000000..8c0822d --- /dev/null +++ b/lib/features/speed_test/domain/entities/speed_test_with_results.dart @@ -0,0 +1,69 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; + +part 'speed_test_with_results.freezed.dart'; + +/// A joined entity containing a speed test configuration with its associated results. +/// Note: This is a view model created in code, not from JSON. +@Freezed(toJson: false, fromJson: false) +class SpeedTestWithResults with _$SpeedTestWithResults { + const factory SpeedTestWithResults({ + required SpeedTestConfig config, + @Default([]) List results, + }) = _SpeedTestWithResults; + + const SpeedTestWithResults._(); + + /// Get the most recent result + SpeedTestResult? get latestResult { + if (results.isEmpty) return null; + return results.reduce((a, b) { + final aTime = a.completedAt ?? a.createdAt ?? DateTime(1970); + final bTime = b.completedAt ?? b.createdAt ?? DateTime(1970); + return aTime.isAfter(bTime) ? a : b; + }); + } + + /// Get the number of results + int get resultCount => results.length; + + /// Check if there are any results + bool get hasResults => results.isNotEmpty; + + /// Get passing results only + List get passingResults => + results.where((r) => r.passed).toList(); + + /// Get failing results only + List get failingResults => + results.where((r) => !r.passed).toList(); + + /// Calculate pass rate as percentage + double get passRate { + if (results.isEmpty) return 0.0; + return (passingResults.length / results.length) * 100; + } + + /// Check if the test is currently passing (based on latest result) + bool get isCurrentlyPassing => latestResult?.passed ?? false; + + /// Check if meets minimum download requirement + bool get meetsDownloadRequirement { + final latest = latestResult; + if (latest?.downloadMbps == null || config.minDownloadMbps == null) { + return true; + } + return latest!.downloadMbps! >= config.minDownloadMbps!; + } + + /// Check if meets minimum upload requirement + bool get meetsUploadRequirement { + final latest = latestResult; + if (latest?.uploadMbps == null || config.minUploadMbps == null) { + return true; + } + return latest!.uploadMbps! >= config.minUploadMbps!; + } +} diff --git a/lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart b/lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart new file mode 100644 index 0000000..05095ea --- /dev/null +++ b/lib/features/speed_test/domain/entities/speed_test_with_results.freezed.dart @@ -0,0 +1,271 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'speed_test_with_results.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$SpeedTestWithResults { + SpeedTestConfig get config => throw _privateConstructorUsedError; + List get results => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when( + TResult Function(SpeedTestConfig config, List results) + $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(SpeedTestConfig config, List results)? + $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen( + TResult Function(SpeedTestConfig config, List results)? + $default, { + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map( + TResult Function(_SpeedTestWithResults value) $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_SpeedTestWithResults value)? $default, + ) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap( + TResult Function(_SpeedTestWithResults value)? $default, { + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $SpeedTestWithResultsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpeedTestWithResultsCopyWith<$Res> { + factory $SpeedTestWithResultsCopyWith(SpeedTestWithResults value, + $Res Function(SpeedTestWithResults) then) = + _$SpeedTestWithResultsCopyWithImpl<$Res, SpeedTestWithResults>; + @useResult + $Res call({SpeedTestConfig config, List results}); + + $SpeedTestConfigCopyWith<$Res> get config; +} + +/// @nodoc +class _$SpeedTestWithResultsCopyWithImpl<$Res, + $Val extends SpeedTestWithResults> + implements $SpeedTestWithResultsCopyWith<$Res> { + _$SpeedTestWithResultsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? config = null, + Object? results = null, + }) { + return _then(_value.copyWith( + config: null == config + ? _value.config + : config // ignore: cast_nullable_to_non_nullable + as SpeedTestConfig, + results: null == results + ? _value.results + : results // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $SpeedTestConfigCopyWith<$Res> get config { + return $SpeedTestConfigCopyWith<$Res>(_value.config, (value) { + return _then(_value.copyWith(config: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$SpeedTestWithResultsImplCopyWith<$Res> + implements $SpeedTestWithResultsCopyWith<$Res> { + factory _$$SpeedTestWithResultsImplCopyWith(_$SpeedTestWithResultsImpl value, + $Res Function(_$SpeedTestWithResultsImpl) then) = + __$$SpeedTestWithResultsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({SpeedTestConfig config, List results}); + + @override + $SpeedTestConfigCopyWith<$Res> get config; +} + +/// @nodoc +class __$$SpeedTestWithResultsImplCopyWithImpl<$Res> + extends _$SpeedTestWithResultsCopyWithImpl<$Res, _$SpeedTestWithResultsImpl> + implements _$$SpeedTestWithResultsImplCopyWith<$Res> { + __$$SpeedTestWithResultsImplCopyWithImpl(_$SpeedTestWithResultsImpl _value, + $Res Function(_$SpeedTestWithResultsImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? config = null, + Object? results = null, + }) { + return _then(_$SpeedTestWithResultsImpl( + config: null == config + ? _value.config + : config // ignore: cast_nullable_to_non_nullable + as SpeedTestConfig, + results: null == results + ? _value._results + : results // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc + +class _$SpeedTestWithResultsImpl extends _SpeedTestWithResults { + const _$SpeedTestWithResultsImpl( + {required this.config, final List results = const []}) + : _results = results, + super._(); + + @override + final SpeedTestConfig config; + final List _results; + @override + @JsonKey() + List get results { + if (_results is EqualUnmodifiableListView) return _results; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_results); + } + + @override + String toString() { + return 'SpeedTestWithResults(config: $config, results: $results)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpeedTestWithResultsImpl && + (identical(other.config, config) || other.config == config) && + const DeepCollectionEquality().equals(other._results, _results)); + } + + @override + int get hashCode => Object.hash( + runtimeType, config, const DeepCollectionEquality().hash(_results)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpeedTestWithResultsImplCopyWith<_$SpeedTestWithResultsImpl> + get copyWith => + __$$SpeedTestWithResultsImplCopyWithImpl<_$SpeedTestWithResultsImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when( + TResult Function(SpeedTestConfig config, List results) + $default, + ) { + return $default(config, results); + } + + @override + @optionalTypeArgs + TResult? whenOrNull( + TResult? Function(SpeedTestConfig config, List results)? + $default, + ) { + return $default?.call(config, results); + } + + @override + @optionalTypeArgs + TResult maybeWhen( + TResult Function(SpeedTestConfig config, List results)? + $default, { + required TResult orElse(), + }) { + if ($default != null) { + return $default(config, results); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map( + TResult Function(_SpeedTestWithResults value) $default, + ) { + return $default(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull( + TResult? Function(_SpeedTestWithResults value)? $default, + ) { + return $default?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap( + TResult Function(_SpeedTestWithResults value)? $default, { + required TResult orElse(), + }) { + if ($default != null) { + return $default(this); + } + return orElse(); + } +} + +abstract class _SpeedTestWithResults extends SpeedTestWithResults { + const factory _SpeedTestWithResults( + {required final SpeedTestConfig config, + final List results}) = _$SpeedTestWithResultsImpl; + const _SpeedTestWithResults._() : super._(); + + @override + SpeedTestConfig get config; + @override + List get results; + @override + @JsonKey(ignore: true) + _$$SpeedTestWithResultsImplCopyWith<_$SpeedTestWithResultsImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/features/speed_test/domain/repositories/speed_test_repository.dart b/lib/features/speed_test/domain/repositories/speed_test_repository.dart index 65bb24f..fd4fb7f 100644 --- a/lib/features/speed_test/domain/repositories/speed_test_repository.dart +++ b/lib/features/speed_test/domain/repositories/speed_test_repository.dart @@ -3,6 +3,7 @@ import 'package:fpdart/fpdart.dart'; import 'package:rgnets_fdk/core/errors/failures.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; /// Repository interface for speed test configurations and results. abstract class SpeedTestRepository { @@ -40,4 +41,15 @@ abstract class SpeedTestRepository { Future> updateSpeedTestResult( SpeedTestResult result, ); + + // ============================================================================ + // Joined Operations + // ============================================================================ + + /// Get a speed test configuration with all its results + Future> getSpeedTestWithResults(int id); + + /// Get all speed test configurations with their results + Future>> + getAllSpeedTestsWithResults(); } diff --git a/lib/features/speed_test/presentation/providers/speed_test_providers.dart b/lib/features/speed_test/presentation/providers/speed_test_providers.dart index fa78bb7..d303341 100644 --- a/lib/features/speed_test/presentation/providers/speed_test_providers.dart +++ b/lib/features/speed_test/presentation/providers/speed_test_providers.dart @@ -1,6 +1,3 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:rgnets_fdk/core/providers/core_providers.dart'; @@ -8,13 +5,10 @@ import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/features/speed_test/data/datasources/speed_test_data_source.dart'; import 'package:rgnets_fdk/features/speed_test/data/datasources/speed_test_websocket_data_source.dart'; import 'package:rgnets_fdk/features/speed_test/data/repositories/speed_test_repository_impl.dart'; -import 'package:rgnets_fdk/features/speed_test/data/services/network_gateway_service.dart'; -import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; import 'package:rgnets_fdk/features/speed_test/domain/repositories/speed_test_repository.dart'; -import 'package:rgnets_fdk/features/speed_test/presentation/state/speed_test_run_state.dart'; part 'speed_test_providers.g.dart'; @@ -175,213 +169,91 @@ class SpeedTestResultsNotifier extends _$SpeedTestResultsNotifier { } // ============================================================================ -// Speed Test Run Notifier (for running tests via Riverpod) +// Speed Test With Results Provider (Joined) // ============================================================================ @Riverpod(keepAlive: true) -class SpeedTestRunNotifier extends _$SpeedTestRunNotifier { - SpeedTestService? _service; - StreamSubscription? _resultSub; - StreamSubscription? _statusSub; - StreamSubscription? _progressSub; - StreamSubscription? _messageSub; +class SpeedTestWithResultsNotifier extends _$SpeedTestWithResultsNotifier { + Logger get _logger => ref.read(loggerProvider); @override - SpeedTestRunState build() { - ref.onDispose(() { - _resultSub?.cancel(); - _statusSub?.cancel(); - _progressSub?.cancel(); - _messageSub?.cancel(); - _service?.dispose(); - }); - return const SpeedTestRunState(); - } + Future build(int configId) async { + _logger.i('SpeedTestWithResultsNotifier: Loading config $configId'); - /// Idempotent initialization - safe to call multiple times - Future initialize() async { - if (state.isInitialized) return; + final repository = ref.watch(speedTestRepositoryProvider); + final result = await repository.getSpeedTestWithResults(configId); - _service = SpeedTestService(); - await _service!.initialize(); - _subscribeToStreams(); - await _syncNetworkInfo(); - _syncConfigFromService(); - state = state.copyWith(isInitialized: true); + return result.fold( + (failure) { + _logger.e( + 'SpeedTestWithResultsNotifier: Failed - ${failure.message}', + ); + throw Exception(failure.message); + }, + (joined) { + _logger.i( + 'SpeedTestWithResultsNotifier: Loaded config $configId ' + 'with ${joined.resultCount} results', + ); + return joined; + }, + ); } - void _subscribeToStreams() { - // Status stream - _statusSub = _service!.statusStream.listen((status) { - state = state.copyWith(executionStatus: status); - }); - - // Progress stream - _progressSub = _service!.progressStream.listen((progress) { - state = state.copyWith(progress: progress); - }); - - // Status message stream - _messageSub = _service!.statusMessageStream.listen((message) { - state = state.copyWith(statusMessage: message); - }); - - // Result stream - _resultSub = _service!.resultStream.listen((result) { - state = state.copyWith( - downloadSpeed: result.downloadMbps ?? state.downloadSpeed, - uploadSpeed: result.uploadMbps ?? state.uploadSpeed, - latency: (result.rtt ?? 0) > 0 ? result.rtt! : state.latency, - completedResult: result, - serverHost: result.serverHost ?? state.serverHost, - errorMessage: result.hasError ? result.errorMessage : null, + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(speedTestRepositoryProvider); + final result = await repository.getSpeedTestWithResults(configId); + return result.fold( + (failure) => throw Exception(failure.message), + (joined) => joined, ); - - // Auto-validate when result comes in - if (state.config != null) { - final passed = _validateResult(state.config!); - state = state.copyWith(testPassed: passed); - } }); } +} - Future _syncNetworkInfo() async { - final networkService = NetworkGatewayService(); - final localIp = await networkService.getWifiIP(); - final gatewayIp = await networkService.getWifiGateway(); - state = state.copyWith( - localIpAddress: localIp, - gatewayAddress: gatewayIp, - ); - } - - void _syncConfigFromService() { - if (_service == null) return; - state = state.copyWith( - serverHost: _service!.serverHost, - serverPort: _service!.serverPort, - testDuration: _service!.testDuration, - bandwidthMbps: _service!.bandwidthMbps, - parallelStreams: _service!.parallelStreams, - useUdp: _service!.useUdp, - ); - } - - Future startTest({ - SpeedTestConfig? config, - String? configTarget, - }) async { - if (!state.isInitialized) await initialize(); - - // Reset for new test - state = state.copyWith( - config: config, - downloadSpeed: 0, - uploadSpeed: 0, - latency: 0, - progress: 0, - errorMessage: null, - statusMessage: null, - testPassed: null, - completedResult: null, - ); - - final target = configTarget ?? config?.target; - - try { - await _service!.runSpeedTestWithFallback(configTarget: target); - } catch (e) { - state = state.copyWith(errorMessage: e.toString()); - } - } - - Future cancelTest() async { - await _service?.cancelTest(); - } +// ============================================================================ +// All Speed Tests With Results Provider +// ============================================================================ - bool _validateResult(SpeedTestConfig config) { - final minDown = config.minDownloadMbps ?? 0; - final minUp = config.minUploadMbps ?? 0; - return state.downloadSpeed >= minDown && state.uploadSpeed >= minUp; - } +@Riverpod(keepAlive: true) +class AllSpeedTestsWithResultsNotifier + extends _$AllSpeedTestsWithResultsNotifier { + Logger get _logger => ref.read(loggerProvider); - void updateConfiguration({ - bool? useUdp, - int? testDuration, - int? bandwidthMbps, - int? parallelStreams, - }) { - if (!state.isInitialized) return; - _service!.updateConfiguration( - useUdp: useUdp, - testDuration: testDuration, - bandwidthMbps: bandwidthMbps, - parallelStreams: parallelStreams, - ); - _syncConfigFromService(); - } + @override + Future> build() async { + _logger.i('AllSpeedTestsWithResultsNotifier: Loading all speed tests'); - /// Submit result via WebSocket (for config-based tests) - /// Returns the created result if submission succeeded, null otherwise - Future submitResult({int? accessPointId}) async { - if (state.completedResult == null) return null; + final repository = ref.watch(speedTestRepositoryProvider); + final result = await repository.getAllSpeedTestsWithResults(); - final result = state.completedResult!.copyWith( - speedTestId: state.config?.id, - passed: state.testPassed ?? false, - accessPointId: accessPointId, - port: state.serverPort, - iperfProtocol: state.useUdp ? 'udp' : 'tcp', + return result.fold( + (failure) { + _logger.e( + 'AllSpeedTestsWithResultsNotifier: Failed - ${failure.message}', + ); + throw Exception(failure.message); + }, + (joined) { + _logger.i( + 'AllSpeedTestsWithResultsNotifier: Loaded ${joined.length} speed tests', + ); + return joined; + }, ); - - try { - final created = await ref - .read(speedTestResultsNotifierProvider( - speedTestId: state.config?.id, - accessPointId: accessPointId, - ).notifier) - .createResult(result); - return created; - } catch (e) { - state = state.copyWith(errorMessage: 'Submission failed: $e'); - return null; - } - } - - /// Submit adhoc result via WebSocket (for card-based adhoc tests) - /// Returns true if submission succeeded - Future submitAdhocResult() async { - if (state.completedResult == null) return false; - - final result = state.completedResult!; - try { - await ref.read(webSocketCacheIntegrationProvider).createAdhocSpeedTestResult( - downloadSpeed: result.downloadMbps ?? 0, - uploadSpeed: result.uploadMbps ?? 0, - latency: result.rtt ?? 0, - source: result.source, - destination: result.destination, - ); - return true; - } catch (e) { - state = state.copyWith(errorMessage: 'Submission failed: $e'); - return false; - } } - void reset() { - if (!state.isInitialized) return; - state = state.copyWith( - executionStatus: SpeedTestStatus.idle, - progress: 0, - statusMessage: null, - downloadSpeed: 0, - uploadSpeed: 0, - latency: 0, - errorMessage: null, - testPassed: null, - completedResult: null, - config: null, - ); + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repository = ref.read(speedTestRepositoryProvider); + final result = await repository.getAllSpeedTestsWithResults(); + return result.fold( + (failure) => throw Exception(failure.message), + (joined) => joined, + ); + }); } } diff --git a/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart b/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart index 05e51a0..ac308b8 100644 --- a/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart +++ b/lib/features/speed_test/presentation/providers/speed_test_providers.g.dart @@ -248,22 +248,172 @@ class _SpeedTestResultsNotifierProviderElement (origin as SpeedTestResultsNotifierProvider).accessPointId; } -String _$speedTestRunNotifierHash() => - r'83cb66e1211ab3f3b1f5a9f72b96c8189b0b8cb5'; - -/// See also [SpeedTestRunNotifier]. -@ProviderFor(SpeedTestRunNotifier) -final speedTestRunNotifierProvider = - NotifierProvider.internal( - SpeedTestRunNotifier.new, - name: r'speedTestRunNotifierProvider', +String _$speedTestWithResultsNotifierHash() => + r'ca7c8b8e92c543d1ca0e47ee04a15a7a328c6d40'; + +abstract class _$SpeedTestWithResultsNotifier + extends BuildlessAsyncNotifier { + late final int configId; + + FutureOr build( + int configId, + ); +} + +/// See also [SpeedTestWithResultsNotifier]. +@ProviderFor(SpeedTestWithResultsNotifier) +const speedTestWithResultsNotifierProvider = + SpeedTestWithResultsNotifierFamily(); + +/// See also [SpeedTestWithResultsNotifier]. +class SpeedTestWithResultsNotifierFamily + extends Family> { + /// See also [SpeedTestWithResultsNotifier]. + const SpeedTestWithResultsNotifierFamily(); + + /// See also [SpeedTestWithResultsNotifier]. + SpeedTestWithResultsNotifierProvider call( + int configId, + ) { + return SpeedTestWithResultsNotifierProvider( + configId, + ); + } + + @override + SpeedTestWithResultsNotifierProvider getProviderOverride( + covariant SpeedTestWithResultsNotifierProvider provider, + ) { + return call( + provider.configId, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'speedTestWithResultsNotifierProvider'; +} + +/// See also [SpeedTestWithResultsNotifier]. +class SpeedTestWithResultsNotifierProvider extends AsyncNotifierProviderImpl< + SpeedTestWithResultsNotifier, SpeedTestWithResults> { + /// See also [SpeedTestWithResultsNotifier]. + SpeedTestWithResultsNotifierProvider( + int configId, + ) : this._internal( + () => SpeedTestWithResultsNotifier()..configId = configId, + from: speedTestWithResultsNotifierProvider, + name: r'speedTestWithResultsNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$speedTestWithResultsNotifierHash, + dependencies: SpeedTestWithResultsNotifierFamily._dependencies, + allTransitiveDependencies: + SpeedTestWithResultsNotifierFamily._allTransitiveDependencies, + configId: configId, + ); + + SpeedTestWithResultsNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.configId, + }) : super.internal(); + + final int configId; + + @override + FutureOr runNotifierBuild( + covariant SpeedTestWithResultsNotifier notifier, + ) { + return notifier.build( + configId, + ); + } + + @override + Override overrideWith(SpeedTestWithResultsNotifier Function() create) { + return ProviderOverride( + origin: this, + override: SpeedTestWithResultsNotifierProvider._internal( + () => create()..configId = configId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + configId: configId, + ), + ); + } + + @override + AsyncNotifierProviderElement createElement() { + return _SpeedTestWithResultsNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SpeedTestWithResultsNotifierProvider && + other.configId == configId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, configId.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin SpeedTestWithResultsNotifierRef + on AsyncNotifierProviderRef { + /// The parameter `configId` of this provider. + int get configId; +} + +class _SpeedTestWithResultsNotifierProviderElement + extends AsyncNotifierProviderElement with SpeedTestWithResultsNotifierRef { + _SpeedTestWithResultsNotifierProviderElement(super.provider); + + @override + int get configId => (origin as SpeedTestWithResultsNotifierProvider).configId; +} + +String _$allSpeedTestsWithResultsNotifierHash() => + r'd773bec35269df06902eed57df641eb48a46c935'; + +/// See also [AllSpeedTestsWithResultsNotifier]. +@ProviderFor(AllSpeedTestsWithResultsNotifier) +final allSpeedTestsWithResultsNotifierProvider = AsyncNotifierProvider< + AllSpeedTestsWithResultsNotifier, List>.internal( + AllSpeedTestsWithResultsNotifier.new, + name: r'allSpeedTestsWithResultsNotifierProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$speedTestRunNotifierHash, + : _$allSpeedTestsWithResultsNotifierHash, dependencies: null, allTransitiveDependencies: null, ); -typedef _$SpeedTestRunNotifier = Notifier; +typedef _$AllSpeedTestsWithResultsNotifier + = AsyncNotifier>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/features/speed_test/presentation/widgets/speed_test_card.dart b/lib/features/speed_test/presentation/widgets/speed_test_card.dart index f120227..a64adc5 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_card.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_card.dart @@ -1,8 +1,9 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; -import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; +import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; @@ -16,13 +17,53 @@ class SpeedTestCard extends ConsumerStatefulWidget { } class _SpeedTestCardState extends ConsumerState { + final SpeedTestService _speedTestService = SpeedTestService(); + SpeedTestStatus _status = SpeedTestStatus.idle; + SpeedTestResult? _lastResult; + double _progress = 0.0; + StreamSubscription? _statusSubscription; + StreamSubscription? _resultSubscription; + StreamSubscription? _progressSubscription; + @override void initState() { super.initState(); - // Initialize the notifier (idempotent - safe to call multiple times) - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(speedTestRunNotifierProvider.notifier).initialize(); + _initializeService(); + } + + Future _initializeService() async { + await _speedTestService.initialize(); + + _status = _speedTestService.status; + _lastResult = _speedTestService.lastResult; + + _statusSubscription = _speedTestService.statusStream.listen((status) { + if (mounted) { + setState(() => _status = status); + } + }); + + _resultSubscription = _speedTestService.resultStream.listen((result) { + if (mounted) { + setState(() => _lastResult = result); + } }); + + _progressSubscription = _speedTestService.progressStream.listen((progress) { + if (mounted) { + setState(() => _progress = progress); + } + }); + + if (mounted) setState(() {}); + } + + @override + void dispose() { + _statusSubscription?.cancel(); + _resultSubscription?.cancel(); + _progressSubscription?.cancel(); + super.dispose(); } String _formatSpeed(double speed) { @@ -33,12 +74,11 @@ class _SpeedTestCardState extends ConsumerState { } } - String _getLastTestTime(SpeedTestResult? lastResult) { - if (lastResult == null) return 'Never'; + String _getLastTestTime() { + if (_lastResult == null) return 'Never'; final now = DateTime.now(); - final timestamp = lastResult.completedAt ?? lastResult.timestamp; - final diff = now.difference(timestamp); + final diff = now.difference(_lastResult!.timestamp); if (diff.inMinutes < 1) { return 'Just now'; @@ -77,18 +117,20 @@ class _SpeedTestCardState extends ConsumerState { Future _showSpeedTestPopup() async { if (!mounted) return; - // Get adhoc config from cache (pre-loaded at WebSocket connect) - final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); - final adhocConfig = cacheIntegration.getAdhocSpeedTestConfig(); + // Get available configs from provider - use first config if available (adhoc) + final configsAsync = ref.read(speedTestConfigsNotifierProvider); + final adhocConfig = configsAsync.whenOrNull( + data: (configs) => configs.isNotEmpty ? configs.first : null, + ); if (adhocConfig != null) { LoggerService.info( - 'Using adhoc config from cache: ${adhocConfig.name} (id: ${adhocConfig.id})', + 'Using adhoc config: ${adhocConfig.name} (id: ${adhocConfig.id})', tag: 'SpeedTestCard', ); } else { LoggerService.info( - 'No configs in cache - running adhoc test without config', + 'No configs available - running adhoc test without config', tag: 'SpeedTestCard', ); } @@ -99,17 +141,21 @@ class _SpeedTestCardState extends ConsumerState { builder: (BuildContext context) { return SpeedTestPopup( cachedTest: adhocConfig, - onCompleted: () { + onCompleted: () async { if (mounted) { LoggerService.info( - 'Speed test completed - UI will update via Riverpod', - tag: 'SpeedTestCard', - ); - } - }, - onResultSubmitted: (result) async { - if (!result.hasError) { - await _submitAdhocResult(result); + 'Speed test completed - reloading result for dashboard', + tag: 'SpeedTestCard'); + + final result = _speedTestService.lastResult; + setState(() { + _lastResult = result; + }); + + // Submit adhoc result to server if test completed successfully + if (result != null && !result.hasError) { + await _submitAdhocResult(result, adhocConfig?.id); + } } }, ); @@ -117,34 +163,62 @@ class _SpeedTestCardState extends ConsumerState { ); } - /// Submit adhoc speed test result to the server via WebSocket cache integration - Future _submitAdhocResult(SpeedTestResult result) async { + /// Submit adhoc speed test result to the server + Future _submitAdhocResult(SpeedTestResult result, int? configId) async { try { LoggerService.info( 'Submitting adhoc speed test result: ' - 'source=${result.source}, ' - 'destination=${result.destination}, ' + 'source=${result.localIpAddress}, ' + 'destination=${result.serverHost}, ' 'download=${result.downloadMbps}, ' 'upload=${result.uploadMbps}, ' 'ping=${result.rtt}', tag: 'SpeedTestCard', ); - final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); - final success = await cacheIntegration.createAdhocSpeedTestResult( - downloadSpeed: result.downloadMbps ?? 0, - uploadSpeed: result.uploadMbps ?? 0, - latency: result.rtt ?? 0, - source: result.source, - destination: result.destination, - port: result.port, - protocol: result.iperfProtocol, - passed: result.passed, + // Check if requirements are met (for pass/fail determination) + bool passed = true; + if (configId != null) { + final configsAsync = ref.read(speedTestConfigsNotifierProvider); + final config = configsAsync.whenOrNull( + data: (configs) => configs.where((c) => c.id == configId).firstOrNull, + ); + + if (config != null) { + final downloadOk = config.minDownloadMbps == null || + (result.downloadMbps ?? 0) >= config.minDownloadMbps!; + final uploadOk = config.minUploadMbps == null || + (result.uploadMbps ?? 0) >= config.minUploadMbps!; + passed = downloadOk && uploadOk; + } + } + + // Create result with all required fields for submission + final resultToSubmit = SpeedTestResult( + speedTestId: configId, + testType: 'iperf3', + source: result.localIpAddress, + destination: result.serverHost, + port: _speedTestService.serverPort, + iperfProtocol: _speedTestService.useUdp ? 'udp' : 'tcp', + downloadMbps: result.downloadMbps, + uploadMbps: result.uploadMbps, + rtt: result.rtt, + jitter: result.jitter, + passed: passed, + completedAt: DateTime.now(), + localIpAddress: result.localIpAddress, + serverHost: result.serverHost, ); - if (success) { + // Submit via provider + final saved = await ref + .read(speedTestResultsNotifierProvider().notifier) + .createResult(resultToSubmit); + + if (saved != null) { LoggerService.info( - 'Adhoc speed test result submitted successfully', + 'Adhoc speed test result submitted successfully: id=${saved.id}', tag: 'SpeedTestCard', ); } else { @@ -165,62 +239,56 @@ class _SpeedTestCardState extends ConsumerState { void _showConfigDialog() { showDialog( context: context, - builder: (BuildContext dialogContext) { - return Consumer( - builder: (context, ref, child) { - final testState = ref.watch(speedTestRunNotifierProvider); - final notifier = ref.read(speedTestRunNotifierProvider.notifier); - - return AlertDialog( - title: const Text('Speed Test Settings'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SwitchListTile( - title: const Text('Use UDP Protocol'), - subtitle: Text(testState.useUdp - ? 'UDP (faster, less reliable)' - : 'TCP (slower, more reliable)'), - value: testState.useUdp, - onChanged: (value) { - notifier.updateConfiguration(useUdp: value); - }, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.router), - title: const Text('Default Gateway'), - subtitle: Text( - '${testState.serverHost}:${testState.serverPort}'), - trailing: const Icon(Icons.info_outline), - ), - ListTile( - title: const Text('Test Duration'), - subtitle: Text('${testState.testDuration} seconds'), - trailing: const Icon(Icons.timer), - ), - ListTile( - title: const Text('Bandwidth Limit'), - subtitle: Text('${testState.bandwidthMbps} Mbps'), - trailing: const Icon(Icons.speed), - ), - ListTile( - title: const Text('Parallel Streams'), - subtitle: Text('${testState.parallelStreams} streams'), - trailing: const Icon(Icons.stream), - ), - ], + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Speed Test Settings'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SwitchListTile( + title: const Text('Use UDP Protocol'), + subtitle: Text(_speedTestService.useUdp + ? 'UDP (faster, less reliable)' + : 'TCP (slower, more reliable)'), + value: _speedTestService.useUdp, + onChanged: (value) { + _speedTestService.updateConfiguration(useUdp: value); + setState(() {}); + }, ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Close'), + const Divider(), + ListTile( + leading: const Icon(Icons.router), + title: const Text('Default Gateway'), + subtitle: Text( + '${_speedTestService.serverHost}:${_speedTestService.serverPort}'), + trailing: const Icon(Icons.info_outline), + ), + ListTile( + title: const Text('Test Duration'), + subtitle: Text('${_speedTestService.testDuration} seconds'), + trailing: const Icon(Icons.timer), + ), + ListTile( + title: const Text('Bandwidth Limit'), + subtitle: Text('${_speedTestService.bandwidthMbps} Mbps'), + trailing: const Icon(Icons.speed), + ), + ListTile( + title: const Text('Parallel Streams'), + subtitle: Text('${_speedTestService.parallelStreams} streams'), + trailing: const Icon(Icons.stream), ), ], - ); - }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], ); }, ); @@ -228,11 +296,6 @@ class _SpeedTestCardState extends ConsumerState { @override Widget build(BuildContext context) { - final testState = ref.watch(speedTestRunNotifierProvider); - final status = testState.executionStatus; - final lastResult = testState.completedResult; - final hasError = lastResult?.hasError == true; - return GestureDetector( onLongPress: _showConfigDialog, child: Card( @@ -247,10 +310,10 @@ class _SpeedTestCardState extends ConsumerState { Row( children: [ Icon( - status == SpeedTestStatus.running + _status == SpeedTestStatus.running ? Icons.speed : Icons.network_check, - color: status == SpeedTestStatus.running + color: _status == SpeedTestStatus.running ? AppColors.primary : AppColors.gray500, size: 20, @@ -266,25 +329,25 @@ class _SpeedTestCardState extends ConsumerState { fontSize: 14, fontWeight: FontWeight.w600), ), Text( - testState.useUdp + _speedTestService.useUdp ? 'UDP Protocol' : 'TCP Protocol', style: TextStyle( fontSize: 10, color: AppColors.gray500), ), - if (testState.localIpAddress != null || - testState.serverHost.isNotEmpty) + if (_lastResult?.localIpAddress != null || + _lastResult?.serverHost != null) Text( - '${testState.localIpAddress ?? "Unknown"} → ${testState.serverHost}', + '${_lastResult?.localIpAddress ?? "Unknown"} → ${_lastResult?.serverHost ?? _speedTestService.serverHost}', style: TextStyle( fontSize: 9, color: AppColors.gray500), ), ], ), ), - if (lastResult != null && !hasError) + if (_lastResult != null && !_lastResult!.hasError) Text( - _getLastTestTime(lastResult), + _getLastTestTime(), style: TextStyle(fontSize: 10, color: AppColors.gray500), ), ], @@ -292,20 +355,20 @@ class _SpeedTestCardState extends ConsumerState { const SizedBox(height: 12), // Results or placeholder - if (lastResult != null && !hasError) ...[ + if (_lastResult != null && !_lastResult!.hasError) ...[ Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildSpeedMetric('Down', testState.downloadSpeed, + _buildSpeedMetric('Down', _lastResult!.downloadSpeed, AppColors.success, Icons.download), - _buildSpeedMetric('Up', testState.uploadSpeed, + _buildSpeedMetric('Up', _lastResult!.uploadSpeed, AppColors.info, Icons.upload), _buildSpeedMetric( - 'Ping', testState.latency, Colors.orange, Icons.timer, + 'Ping', _lastResult!.latency, Colors.orange, Icons.timer, isLatency: true), ], ), - ] else if (hasError) ...[ + ] else if (_lastResult?.hasError == true) ...[ Center( child: Column( children: [ @@ -315,9 +378,9 @@ class _SpeedTestCardState extends ConsumerState { const Text('Test failed', style: TextStyle(color: AppColors.error, fontSize: 12)), - if (testState.errorMessage != null) + if (_lastResult!.errorMessage != null) Text( - testState.errorMessage!, + _lastResult!.errorMessage!, style: const TextStyle( color: AppColors.error, fontSize: 10), textAlign: TextAlign.center, @@ -340,10 +403,10 @@ class _SpeedTestCardState extends ConsumerState { ], // Progress bar - if (status == SpeedTestStatus.running) ...[ + if (_status == SpeedTestStatus.running) ...[ const SizedBox(height: 12), LinearProgressIndicator( - value: testState.progress / 100, + value: _progress / 100, backgroundColor: AppColors.gray700, valueColor: const AlwaysStoppedAnimation(AppColors.primary), @@ -353,12 +416,12 @@ class _SpeedTestCardState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${testState.progress.toStringAsFixed(0)}% Complete', + '${_progress.toStringAsFixed(0)}% Complete', style: TextStyle(fontSize: 10, color: AppColors.gray500), ), - if (testState.serverHost.isNotEmpty) + if (_speedTestService.serverHost.isNotEmpty) Text( - 'Testing to ${testState.serverHost}', + 'Testing to ${_speedTestService.serverHost}', style: TextStyle( fontSize: 9, color: AppColors.gray500, @@ -373,23 +436,27 @@ class _SpeedTestCardState extends ConsumerState { // Action button Center( child: ElevatedButton.icon( - onPressed: status == SpeedTestStatus.running + onPressed: _status == SpeedTestStatus.running ? null : _showSpeedTestPopup, icon: Icon( - status == SpeedTestStatus.running + _status == SpeedTestStatus.running ? Icons.speed - : (hasError ? Icons.refresh : Icons.play_arrow), + : (_lastResult?.hasError == true + ? Icons.refresh + : Icons.play_arrow), size: 14, ), label: Text( - status == SpeedTestStatus.running + _status == SpeedTestStatus.running ? 'Test Running...' - : (hasError ? 'Retry Test' : 'Run Test'), + : (_lastResult?.hasError == true + ? 'Retry Test' + : 'Run Test'), style: const TextStyle(fontSize: 11), ), style: ElevatedButton.styleFrom( - backgroundColor: status == SpeedTestStatus.running + backgroundColor: _status == SpeedTestStatus.running ? AppColors.gray600 : AppColors.primary, foregroundColor: Colors.white, diff --git a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart index fc4fdaf..cf34c84 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart @@ -1,58 +1,68 @@ +import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; -import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; +import 'package:rgnets_fdk/features/speed_test/data/services/network_gateway_service.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; -import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; -class SpeedTestPopup extends ConsumerStatefulWidget { - /// The speed test configuration +class SpeedTestPopup extends StatefulWidget { + /// The speed test configuration (use this OR [speedTestWithResults]) final SpeedTestConfig? cachedTest; - final VoidCallback? onCompleted; - - /// Callback when result should be submitted (auto-called when test passes) - final void Function(SpeedTestResult result)? onResultSubmitted; - - /// Existing result to update (instead of creating a new one) - final SpeedTestResult? existingResult; + /// The joined speed test with results (use this OR [cachedTest]) + /// If provided, the config will be extracted from this + final SpeedTestWithResults? speedTestWithResults; - /// Optional AP ID to display uplink speed (for AP speed tests) - final int? apId; + final VoidCallback? onCompleted; const SpeedTestPopup({ super.key, this.cachedTest, + this.speedTestWithResults, this.onCompleted, - this.onResultSubmitted, - this.existingResult, - this.apId, - }); + }) : assert( + cachedTest != null || speedTestWithResults != null || true, + 'Either cachedTest or speedTestWithResults can be provided, or neither for a standalone test', + ); @override - ConsumerState createState() => _SpeedTestPopupState(); + State createState() => _SpeedTestPopupState(); } -class _SpeedTestPopupState extends ConsumerState +class _SpeedTestPopupState extends State with SingleTickerProviderStateMixin { + final SpeedTestService _speedTestService = SpeedTestService(); + + SpeedTestStatus _status = SpeedTestStatus.idle; + double _downloadSpeed = 0.0; + double _uploadSpeed = 0.0; + double _latency = 0.0; + double _progress = 0.0; + String _currentPhase = 'Ready to start'; + String? _localIp; + String? _gatewayIp; + String? _serverHost; + String _serverLabel = 'Gateway'; + String? _errorMessage; + bool _testPassed = false; + + StreamSubscription? _statusSubscription; + StreamSubscription? _resultSubscription; + StreamSubscription? _progressSubscription; + StreamSubscription? _statusMessageSubscription; + late AnimationController _pulseController; late Animation _pulseAnimation; - bool _resultSubmitted = false; - bool _isSubmitting = false; - bool? _submissionSuccess; - String? _submissionError; @override void initState() { super.initState(); _initializePulseAnimation(); - // Initialize notifier (idempotent) - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(speedTestRunNotifierProvider.notifier).initialize(); - }); + _initializeService(); } void _initializePulseAnimation() { @@ -66,184 +76,206 @@ class _SpeedTestPopupState extends ConsumerState ); } - @override - void dispose() { - _pulseController.dispose(); - super.dispose(); - } + Future _initializeService() async { + await _speedTestService.initialize(); - /// Get the effective config - SpeedTestConfig? get _effectiveConfig => widget.cachedTest; + _status = SpeedTestStatus.idle; - double? _getMinDownload() => _effectiveConfig?.minDownloadMbps; - double? _getMinUpload() => _effectiveConfig?.minUploadMbps; - String? _getConfigTarget() => _effectiveConfig?.target; - String? _getConfigName() => _effectiveConfig?.name; + final gatewayService = NetworkGatewayService(); + _localIp = await gatewayService.getWifiIP(); - Future _startTest() async { - final notifier = ref.read(speedTestRunNotifierProvider.notifier); - final configTarget = _getConfigTarget(); + _gatewayIp = await gatewayService.getWifiGateway(); + _serverHost = _gatewayIp; + _serverLabel = 'Gateway'; - await notifier.startTest( - config: _effectiveConfig, - configTarget: configTarget, - ); - } + if (_localIp == null) { + LoggerService.warning( + 'Could not get device IP - location permission may be required on iOS', + tag: 'SpeedTestPopup'); + } - Future _cancelTest() async { - await ref.read(speedTestRunNotifierProvider.notifier).cancelTest(); if (mounted) { - Navigator.of(context).pop(); + setState(() {}); } + + _statusSubscription = _speedTestService.statusStream.listen((status) { + if (!mounted) return; + setState(() { + _status = status; + _updatePhase(); + }); + }); + + _resultSubscription = _speedTestService.resultStream.listen((result) { + if (!mounted) return; + setState(() { + final serviceStatus = _speedTestService.status; + + if (result.hasError) { + _errorMessage = result.errorMessage; + _currentPhase = 'Test failed'; + } else { + // Update speeds (either live or final) + if (result.downloadSpeed > 0) _downloadSpeed = result.downloadSpeed; + if (result.uploadSpeed > 0) _uploadSpeed = result.uploadSpeed; + if (result.latency > 0) _latency = result.latency; + + // Only update connection info if it's a final result + if (result.localIpAddress != null) _localIp = result.localIpAddress; + if (result.serverHost != null) _serverHost = result.serverHost; + + // If the service finished but our local status hasn't updated yet, sync it + if (serviceStatus == SpeedTestStatus.completed && + _status != SpeedTestStatus.completed) { + _status = SpeedTestStatus.completed; + } + + // Check if this is a complete result + if (result.localIpAddress != null || + _status == SpeedTestStatus.completed || + serviceStatus == SpeedTestStatus.completed) { + _validateTestResults(); + _currentPhase = + _testPassed ? 'Test completed - PASSED!' : 'Test completed'; + } + } + }); + }); + + _progressSubscription = _speedTestService.progressStream.listen((progress) { + if (!mounted) return; + setState(() { + _progress = progress; + _updatePhase(); + }); + }); + + _statusMessageSubscription = + _speedTestService.statusMessageStream.listen((message) { + if (!mounted) return; + setState(() { + _currentPhase = message; + + // Extract server info from fallback attempt messages + if (message.contains('Default gateway')) { + _serverLabel = 'Gateway'; + final match = RegExp(r'\(([^)]+)\)').firstMatch(message); + if (match != null) { + _serverHost = match.group(1); + } + } else if (message.contains('test configuration') || + message.contains('Test configuration')) { + _serverLabel = 'Target'; + final match = RegExp(r'\(([^)]+)\)').firstMatch(message); + if (match != null) { + _serverHost = match.group(1); + } + } else if (message.contains('external server') || + message.contains('External server')) { + _serverLabel = 'External'; + final match = RegExp(r'\(([^)]+)\)').firstMatch(message); + if (match != null) { + _serverHost = match.group(1); + } + } else if (message.contains('Testing download speed to') || + message.contains('Testing upload speed to')) { + final match = RegExp(r'to ([\w\.\-]+)').firstMatch(message); + if (match != null) { + _serverHost = match.group(1); + _serverLabel = (_serverHost == _gatewayIp) ? 'Gateway' : 'Target'; + } + } + }); + }); } - Future _handleTestCompleted() async { - if (_resultSubmitted) return; - - final testState = ref.read(speedTestRunNotifierProvider); - final result = testState.completedResult; - - if (result == null) return; - - // Submit result via callback if provided - if (widget.onResultSubmitted != null) { - final submitResult = SpeedTestResult( - downloadMbps: testState.downloadSpeed, - uploadMbps: testState.uploadSpeed, - rtt: testState.latency, - localIpAddress: testState.localIpAddress, - serverHost: testState.serverHost, - speedTestId: _effectiveConfig?.id, - passed: testState.testPassed ?? false, - initiatedAt: result.initiatedAt, - completedAt: DateTime.now(), - testType: 'iperf3', - source: testState.localIpAddress, - destination: testState.serverHost, - port: testState.serverPort, - iperfProtocol: testState.useUdp ? 'udp' : 'tcp', - ); - - LoggerService.info( - 'SpeedTestPopup: Auto-submitting result via callback - passed=${testState.testPassed}, ' - 'download=${testState.downloadSpeed}, upload=${testState.uploadSpeed}', - tag: 'SpeedTestPopup', - ); - - widget.onResultSubmitted?.call(submitResult); - _resultSubmitted = true; - } else if (_effectiveConfig != null) { - // No callback provided, submit internally via provider - await _submitResultInternally(); + void _updatePhase() { + if (_status == SpeedTestStatus.running && + _currentPhase == 'Ready to start') { + if (_progress < 50) { + _currentPhase = 'Testing download speed...'; + } else { + _currentPhase = 'Testing upload speed...'; + } + } else if (_status == SpeedTestStatus.completed && + _currentPhase != 'Test completed!') { + _currentPhase = 'Test completed!'; + } else if (_status == SpeedTestStatus.error && + _currentPhase != 'Test failed') { + _currentPhase = 'Test failed'; } } - Future _submitResultInternally() async { - if (_isSubmitting || _resultSubmitted) return; + Future _startTest() async { + final gatewayService = NetworkGatewayService(); + final gatewayIp = await gatewayService.getWifiGateway(); setState(() { - _isSubmitting = true; - _submissionSuccess = null; - _submissionError = null; + _currentPhase = 'Starting test...'; + _serverLabel = 'Gateway'; + _serverHost = gatewayIp ?? 'Detecting...'; }); - try { - final existingId = widget.existingResult?.id; - final isUpdate = existingId != null; - - LoggerService.info( - 'SpeedTestPopup: existingResult=${widget.existingResult != null}, ' - 'existingId=$existingId, isUpdate=$isUpdate, ' - 'configId=${_effectiveConfig?.id}', - tag: 'SpeedTestPopup', - ); - - SpeedTestResult? resultFromServer; - - if (isUpdate) { - // Update existing result with new test data - final testState = ref.read(speedTestRunNotifierProvider); - final completedResult = testState.completedResult; - - final updatedResult = widget.existingResult!.copyWith( - downloadMbps: testState.downloadSpeed, - uploadMbps: testState.uploadSpeed, - rtt: testState.latency, - passed: testState.testPassed ?? false, - initiatedAt: completedResult?.initiatedAt ?? DateTime.now(), - completedAt: DateTime.now(), - testType: 'iperf3', - source: testState.localIpAddress, - destination: testState.serverHost, - port: testState.serverPort, - iperfProtocol: testState.useUdp ? 'udp' : 'tcp', - ); + // Get target from effective config (works with both cachedTest and speedTestWithResults) + final configTarget = _getConfigTarget(); - LoggerService.info( - 'SpeedTestPopup: Updating result id=$existingId with ' - 'download=${testState.downloadSpeed}, upload=${testState.uploadSpeed}, ' - 'source=${testState.localIpAddress}, destination=${testState.serverHost}', - tag: 'SpeedTestPopup', - ); + // Run test: tries local gateway first, then falls back to config target + await _speedTestService.runSpeedTestWithFallback(configTarget: configTarget); + } - final notifier = ref.read( - speedTestResultsNotifierProvider( - speedTestId: updatedResult.speedTestId, - ).notifier, - ); - resultFromServer = await notifier.updateResult(updatedResult); - } else { - // Create new result - LoggerService.info( - 'SpeedTestPopup: Creating new result (no existing result to update)', - tag: 'SpeedTestPopup', - ); - resultFromServer = await ref - .read(speedTestRunNotifierProvider.notifier) - .submitResult(); - } + void _cancelTest() async { + await _speedTestService.cancelTest(); + if (mounted) { + Navigator.of(context).pop(); + } + } + + /// Get the effective config from either cachedTest or speedTestWithResults + SpeedTestConfig? get _effectiveConfig { + return widget.cachedTest ?? widget.speedTestWithResults?.config; + } - final success = resultFromServer != null; + double? _getMinDownload() { + return _effectiveConfig?.minDownloadMbps; + } - // Update the WebSocket cache so it appears in the list - if (resultFromServer != null) { - ref - .read(webSocketCacheIntegrationProvider) - .updateSpeedTestResultInCache(resultFromServer); - LoggerService.info( - 'SpeedTestPopup: ${isUpdate ? "Updated" : "Added"} result ${resultFromServer.id} in cache', - tag: 'SpeedTestPopup', - ); - } + double? _getMinUpload() { + return _effectiveConfig?.minUploadMbps; + } - if (mounted) { - setState(() { - _isSubmitting = false; - _submissionSuccess = success; - _resultSubmitted = true; - if (!success) { - _submissionError = 'Failed to ${isUpdate ? "update" : "submit"} result'; - } - }); - } + String? _getConfigTarget() { + return _effectiveConfig?.target; + } - LoggerService.info( - 'SpeedTestPopup: ${isUpdate ? "Update" : "Submission"} ${success ? "succeeded" : "failed"}', - tag: 'SpeedTestPopup', - ); - } catch (e) { - LoggerService.error( - 'SpeedTestPopup: Error submitting result: $e', - tag: 'SpeedTestPopup', - ); - if (mounted) { - setState(() { - _isSubmitting = false; - _submissionSuccess = false; - _submissionError = e.toString(); - }); - } + String? _getConfigName() { + return _effectiveConfig?.name; + } + + void _validateTestResults() { + final config = _effectiveConfig; + + if (config == null) { + _testPassed = true; + return; } + + final minDownload = _getMinDownload(); + final minUpload = _getMinUpload(); + + final downloadPassed = minDownload == null || _downloadSpeed >= minDownload; + final uploadPassed = minUpload == null || _uploadSpeed >= minUpload; + + _testPassed = downloadPassed && uploadPassed; + } + + @override + void dispose() { + _statusSubscription?.cancel(); + _resultSubscription?.cancel(); + _progressSubscription?.cancel(); + _statusMessageSubscription?.cancel(); + _pulseController.dispose(); + super.dispose(); } String _formatSpeed(double speed) { @@ -254,8 +286,8 @@ class _SpeedTestPopupState extends ConsumerState } } - Color _getStatusColor(SpeedTestStatus status) { - switch (status) { + Color _getStatusColor() { + switch (_status) { case SpeedTestStatus.running: return AppColors.primary; case SpeedTestStatus.completed: @@ -267,8 +299,8 @@ class _SpeedTestPopupState extends ConsumerState } } - IconData _getStatusIcon(SpeedTestStatus status) { - switch (status) { + IconData _getStatusIcon() { + switch (_status) { case SpeedTestStatus.running: return Icons.speed; case SpeedTestStatus.completed: @@ -326,28 +358,28 @@ class _SpeedTestPopupState extends ConsumerState color: AppColors.gray500, ), ), - const SizedBox(height: 2), - SizedBox( - width: 110, - child: Text( - minRequired != null - ? 'Min: ${_formatSpeed(minRequired)}' - : 'Min: Not set', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 8, - color: AppColors.gray400, - fontStyle: FontStyle.italic, - fontFamily: 'monospace', + if (minRequired != null) ...[ + const SizedBox(height: 2), + SizedBox( + width: 110, + child: Text( + 'Min: ${_formatSpeed(minRequired)}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 8, + color: AppColors.gray400, + fontStyle: FontStyle.italic, + fontFamily: 'monospace', + ), ), ), - ), + ], ], ), ); } - Widget _buildLatencyIndicator(double latency) { + Widget _buildLatencyIndicator() { return Container( padding: const EdgeInsets.all(12), constraints: const BoxConstraints( @@ -367,7 +399,7 @@ class _SpeedTestPopupState extends ConsumerState SizedBox( width: 110, child: Text( - '${latency.toStringAsFixed(0)} ms', + '${_latency.toStringAsFixed(0)} ms', textAlign: TextAlign.center, style: const TextStyle( fontSize: 14, @@ -392,19 +424,8 @@ class _SpeedTestPopupState extends ConsumerState @override Widget build(BuildContext context) { - final testState = ref.watch(speedTestRunNotifierProvider); - final status = testState.executionStatus; - final testPassed = testState.testPassed; - - // Auto-submit when test completes - if (status == SpeedTestStatus.completed && !_resultSubmitted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _handleTestCompleted(); - }); - } - return PopScope( - canPop: status != SpeedTestStatus.running, + canPop: _status != SpeedTestStatus.running, child: Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Container( @@ -421,18 +442,18 @@ class _SpeedTestPopupState extends ConsumerState animation: _pulseAnimation, builder: (context, child) { return Transform.scale( - scale: status == SpeedTestStatus.running + scale: _status == SpeedTestStatus.running ? _pulseAnimation.value : 1.0, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: _getStatusColor(status).withOpacity(0.2), + color: _getStatusColor().withOpacity(0.2), shape: BoxShape.circle, ), child: Icon( - _getStatusIcon(status), - color: _getStatusColor(status), + _getStatusIcon(), + color: _getStatusColor(), size: 32, ), ), @@ -452,7 +473,7 @@ class _SpeedTestPopupState extends ConsumerState ), ), Text( - testState.statusMessage ?? 'Ready to start', + _currentPhase, style: TextStyle( fontSize: 14, color: AppColors.gray500, @@ -461,7 +482,7 @@ class _SpeedTestPopupState extends ConsumerState ], ), ), - if (status != SpeedTestStatus.running) + if (_status != SpeedTestStatus.running) IconButton( icon: const Icon(Icons.close), onPressed: () { @@ -488,11 +509,11 @@ class _SpeedTestPopupState extends ConsumerState Row( children: [ Icon( - testState.localIpAddress != null + _localIp != null ? Icons.computer : Icons.location_off, size: 16, - color: testState.localIpAddress != null + color: _localIp != null ? AppColors.gray500 : Colors.orange, ), @@ -504,9 +525,9 @@ class _SpeedTestPopupState extends ConsumerState color: AppColors.gray500, ), ), - if (testState.localIpAddress != null) + if (_localIp != null) Text( - testState.localIpAddress!, + _localIp!, style: TextStyle( fontSize: 12, color: AppColors.gray300, @@ -539,18 +560,18 @@ class _SpeedTestPopupState extends ConsumerState ), ), Text( - 'Target', + _serverLabel, style: TextStyle( fontSize: 12, color: AppColors.primary, fontWeight: FontWeight.bold, ), ), - if (testState.serverHost.isNotEmpty) ...[ + if (_serverHost != null) ...[ const SizedBox(width: 4), Flexible( child: Text( - '(${testState.serverHost})', + '($_serverHost)', style: TextStyle( fontSize: 11, color: AppColors.gray500, @@ -562,79 +583,6 @@ class _SpeedTestPopupState extends ConsumerState ], ], ), - // Uplink speed row (for AP speed tests) - if (widget.apId != null) ...[ - const SizedBox(height: 6), - Consumer( - builder: (context, ref, _) { - final uplinkAsync = - ref.watch(apUplinkInfoProvider(widget.apId!)); - return Row( - children: [ - const Icon( - Icons.cable, - size: 16, - color: AppColors.gray500, - ), - const SizedBox(width: 8), - const Text( - 'Uplink: ', - style: TextStyle( - fontSize: 12, - color: AppColors.gray500, - ), - ), - uplinkAsync.when( - data: (uplink) { - if (uplink == null) { - return const Text( - 'Not available', - style: TextStyle( - fontSize: 12, - color: AppColors.warning, - ), - ); - } - final speedBps = uplink.speedInBps; - final speedGbps = speedBps != null - ? speedBps / 1000000000 - : null; - final isSlowUplink = speedBps != null && - speedBps < 2500000000; - return Text( - speedGbps != null - ? '${speedGbps.toStringAsFixed(1)} Gbps' - : 'Unknown', - style: TextStyle( - fontSize: 12, - color: isSlowUplink - ? AppColors.error - : AppColors.gray300, - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - ), - ); - }, - loading: () => const Text( - 'Loading...', - style: TextStyle( - fontSize: 12, - color: AppColors.gray500, - ), - ), - error: (_, __) => const Text( - 'Error', - style: TextStyle( - fontSize: 12, - color: AppColors.error, - ), - ), - ), - ], - ); - }, - ), - ], ], ), ), @@ -783,7 +731,7 @@ class _SpeedTestPopupState extends ConsumerState Expanded( child: _buildSpeedIndicator( 'Download', - testState.downloadSpeed, + _downloadSpeed, Icons.download, AppColors.success, minRequired: _getMinDownload(), @@ -793,7 +741,7 @@ class _SpeedTestPopupState extends ConsumerState Expanded( child: _buildSpeedIndicator( 'Upload', - testState.uploadSpeed, + _uploadSpeed, Icons.upload, AppColors.info, minRequired: _getMinUpload(), @@ -808,13 +756,13 @@ class _SpeedTestPopupState extends ConsumerState // Latency indicator SizedBox( height: 110, - child: _buildLatencyIndicator(testState.latency), + child: _buildLatencyIndicator(), ), const SizedBox(height: 20), // Progress indicator - if (status == SpeedTestStatus.running) ...[ + if (_status == SpeedTestStatus.running) ...[ Center( child: Column( children: [ @@ -822,18 +770,18 @@ class _SpeedTestPopupState extends ConsumerState width: 48, height: 48, child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - _getStatusColor(status)), + valueColor: + AlwaysStoppedAnimation(_getStatusColor()), strokeWidth: 4, ), ), const SizedBox(height: 12), Text( - testState.statusMessage ?? 'Testing...', + _currentPhase, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: _getStatusColor(status), + color: _getStatusColor(), ), ), ], @@ -842,15 +790,14 @@ class _SpeedTestPopupState extends ConsumerState ], // Error message - if (testState.errorMessage != null) ...[ + if (_errorMessage != null) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.error.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: - Border.all(color: AppColors.error.withOpacity(0.3)), + border: Border.all(color: AppColors.error.withOpacity(0.3)), ), child: Row( children: [ @@ -859,7 +806,7 @@ class _SpeedTestPopupState extends ConsumerState const SizedBox(width: 8), Expanded( child: Text( - testState.errorMessage!, + _errorMessage!, style: const TextStyle( color: AppColors.error, fontSize: 12, @@ -872,16 +819,15 @@ class _SpeedTestPopupState extends ConsumerState ], // Threshold failure alert - if (status == SpeedTestStatus.completed && - testPassed == false) ...[ + if (_status == SpeedTestStatus.completed && !_testPassed) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.warning.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.warning.withOpacity(0.3)), + border: + Border.all(color: AppColors.warning.withOpacity(0.3)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -917,115 +863,10 @@ class _SpeedTestPopupState extends ConsumerState ), ], - // Submission status feedback - if (status == SpeedTestStatus.completed && _effectiveConfig != null) ...[ - const SizedBox(height: 16), - if (_isSubmitting) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.primary.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.primary, - ), - ), - const SizedBox(width: 12), - Text( - 'Submitting result...', - style: TextStyle( - color: AppColors.primary, - fontSize: 13, - ), - ), - ], - ), - ) - else if (_submissionSuccess == true) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.success.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.success.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Icon( - Icons.check_circle, - color: AppColors.success, - size: 20, - ), - const SizedBox(width: 12), - Text( - 'Result submitted successfully', - style: TextStyle( - color: AppColors.success, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ) - else if (_submissionSuccess == false) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.error.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.error.withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Icon( - Icons.error_outline, - color: AppColors.error, - size: 20, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - _submissionError ?? 'Failed to submit result', - style: TextStyle( - color: AppColors.error, - fontSize: 13, - ), - ), - ), - TextButton( - onPressed: _submitResultInternally, - child: Text( - 'Retry', - style: TextStyle( - color: AppColors.error, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - ], - // Action buttons const SizedBox(height: 16), - if (status == SpeedTestStatus.idle) ...[ + if (_status == SpeedTestStatus.idle) ...[ Row( children: [ Expanded( @@ -1059,7 +900,7 @@ class _SpeedTestPopupState extends ConsumerState ), ], - if (status == SpeedTestStatus.running) ...[ + if (_status == SpeedTestStatus.running) ...[ SizedBox( width: double.infinity, child: OutlinedButton.icon( @@ -1074,7 +915,7 @@ class _SpeedTestPopupState extends ConsumerState ), ], - if (status == SpeedTestStatus.completed) ...[ + if (_status == SpeedTestStatus.completed) ...[ Row( children: [ Expanded( @@ -1098,14 +939,13 @@ class _SpeedTestPopupState extends ConsumerState child: ElevatedButton.icon( onPressed: () { setState(() { - _resultSubmitted = false; - _isSubmitting = false; - _submissionSuccess = null; - _submissionError = null; + _downloadSpeed = 0.0; + _uploadSpeed = 0.0; + _latency = 0.0; + _progress = 0.0; + _errorMessage = null; + _testPassed = false; }); - ref - .read(speedTestRunNotifierProvider.notifier) - .reset(); _startTest(); }, icon: const Icon(Icons.refresh, size: 16), @@ -1122,7 +962,7 @@ class _SpeedTestPopupState extends ConsumerState ), ], - if (status == SpeedTestStatus.error) ...[ + if (_status == SpeedTestStatus.error) ...[ Row( children: [ Expanded( @@ -1145,14 +985,12 @@ class _SpeedTestPopupState extends ConsumerState child: ElevatedButton.icon( onPressed: () { setState(() { - _resultSubmitted = false; - _isSubmitting = false; - _submissionSuccess = null; - _submissionError = null; + _errorMessage = null; + _downloadSpeed = 0.0; + _uploadSpeed = 0.0; + _latency = 0.0; + _progress = 0.0; }); - ref - .read(speedTestRunNotifierProvider.notifier) - .reset(); _startTest(); }, icon: const Icon(Icons.refresh, size: 16), From 9f407e82e3e05b61ee9fcbdfd9a3eacf8e129db5 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Tue, 20 Jan 2026 09:32:41 -0800 Subject: [PATCH 21/24] Submission format --- .../presentation/widgets/speed_test_card.dart | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/lib/features/speed_test/presentation/widgets/speed_test_card.dart b/lib/features/speed_test/presentation/widgets/speed_test_card.dart index a64adc5..0ebeda0 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_card.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_card.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; @@ -117,20 +118,18 @@ class _SpeedTestCardState extends ConsumerState { Future _showSpeedTestPopup() async { if (!mounted) return; - // Get available configs from provider - use first config if available (adhoc) - final configsAsync = ref.read(speedTestConfigsNotifierProvider); - final adhocConfig = configsAsync.whenOrNull( - data: (configs) => configs.isNotEmpty ? configs.first : null, - ); + // Get adhoc config from cache (pre-loaded at WebSocket connect) + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + final adhocConfig = cacheIntegration.getAdhocSpeedTestConfig(); if (adhocConfig != null) { LoggerService.info( - 'Using adhoc config: ${adhocConfig.name} (id: ${adhocConfig.id})', + 'Using adhoc config from cache: ${adhocConfig.name} (id: ${adhocConfig.id})', tag: 'SpeedTestCard', ); } else { LoggerService.info( - 'No configs available - running adhoc test without config', + 'No configs in cache - running adhoc test without config', tag: 'SpeedTestCard', ); } @@ -179,10 +178,9 @@ class _SpeedTestCardState extends ConsumerState { // Check if requirements are met (for pass/fail determination) bool passed = true; if (configId != null) { - final configsAsync = ref.read(speedTestConfigsNotifierProvider); - final config = configsAsync.whenOrNull( - data: (configs) => configs.where((c) => c.id == configId).firstOrNull, - ); + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + final configs = cacheIntegration.getCachedSpeedTestConfigs(); + final config = configs.where((c) => c.id == configId).firstOrNull; if (config != null) { final downloadOk = config.minDownloadMbps == null || From c1a131848d149fe6288acbddebd639c9854c0153 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Tue, 20 Jan 2026 14:58:12 -0800 Subject: [PATCH 22/24] Need guidance continue tomorrow --- .../widgets/device_speed_test_section.dart | 28 +- .../domain/entities/speed_test_result.dart | 74 ++- .../entities/speed_test_result.freezed.dart | 485 ++++++++++-------- .../domain/entities/speed_test_result.g.dart | 34 +- .../presentation/widgets/speed_test_card.dart | 80 +-- .../widgets/speed_test_popup.dart | 47 +- 6 files changed, 407 insertions(+), 341 deletions(-) diff --git a/lib/features/devices/presentation/widgets/device_speed_test_section.dart b/lib/features/devices/presentation/widgets/device_speed_test_section.dart index 609d742..bbba3fb 100644 --- a/lib/features/devices/presentation/widgets/device_speed_test_section.dart +++ b/lib/features/devices/presentation/widgets/device_speed_test_section.dart @@ -6,7 +6,6 @@ import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/widgets/widgets.dart'; import 'package:rgnets_fdk/features/devices/domain/constants/device_types.dart'; import 'package:rgnets_fdk/features/devices/domain/entities/device.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; @@ -113,27 +112,13 @@ class _DeviceSpeedTestSectionState Future _runSpeedTest() async { if (!mounted) return; + // Get adhoc config from cache final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + final adhocConfig = cacheIntegration.getAdhocSpeedTestConfig(); - // Try to get config from the device's existing results (uses the same test config) - SpeedTestConfig? config; - if (_deviceResults.isNotEmpty) { - final speedTestId = _deviceResults.first.speedTestId; - config = cacheIntegration.getSpeedTestConfigById(speedTestId); - if (config != null) { - LoggerService.info( - 'Running speed test for device ${_getPrefixedDeviceId()} with config from result: ${config.name} (id: $speedTestId)', - tag: 'DeviceSpeedTestSection', - ); - } - } - - // Fall back to adhoc config if no matching config found - config ??= cacheIntegration.getAdhocSpeedTestConfig(); - - if (config != null) { + if (adhocConfig != null) { LoggerService.info( - 'Running speed test for device ${_getPrefixedDeviceId()} with config: ${config.name}', + 'Running speed test for device ${_getPrefixedDeviceId()} with adhoc config: ${adhocConfig.name}', tag: 'DeviceSpeedTestSection', ); } @@ -143,10 +128,7 @@ class _DeviceSpeedTestSectionState barrierDismissible: true, builder: (BuildContext context) { return SpeedTestPopup( - cachedTest: config, - apId: widget.device.type == DeviceTypes.accessPoint - ? _getNumericDeviceId() - : null, + cachedTest: adhocConfig, onCompleted: () { if (mounted) { // Reload results after test completion diff --git a/lib/features/speed_test/domain/entities/speed_test_result.dart b/lib/features/speed_test/domain/entities/speed_test_result.dart index 120c46f..ace229b 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.dart @@ -4,38 +4,57 @@ import 'package:rgnets_fdk/core/services/logger_service.dart'; part 'speed_test_result.freezed.dart'; part 'speed_test_result.g.dart'; +/// Safely converts a value to int, handling strings and nulls +int? _toInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + +/// Safely converts a value to double, handling strings and nulls +double? _toDouble(dynamic value) { + if (value == null) return null; + if (value is double) return value; + if (value is int) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; +} + @freezed class SpeedTestResult with _$SpeedTestResult { const factory SpeedTestResult({ - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? packetLoss, @Default(false) bool passed, @JsonKey(name: 'is_applicable') @Default(true) bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -74,8 +93,9 @@ class SpeedTestResult with _$SpeedTestResult { /// Pre-process JSON to detect and correct swapped download/upload values static Map _preprocessJson(Map json) { - final download = _parseDecimal(json['download_mbps']); - final upload = _parseDecimal(json['upload_mbps']); + final normalizedJson = _normalizeTestedViaAccessPointId(json); + final download = _parseDecimal(normalizedJson['download_mbps']); + final upload = _parseDecimal(normalizedJson['upload_mbps']); if (download == null || upload == null) { return json; @@ -109,12 +129,28 @@ class SpeedTestResult with _$SpeedTestResult { ); // Create a new map with swapped values return { - ...json, + ...normalizedJson, 'download_mbps': upload, 'upload_mbps': download, }; } + return normalizedJson; + } + + static Map _normalizeTestedViaAccessPointId( + Map json, + ) { + final value = json['tested_via_access_point_id']; + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) { + return { + ...json, + 'tested_via_access_point_id': parsed, + }; + } + } return json; } diff --git a/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart b/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart index 2076854..9eedc6a 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.freezed.dart @@ -20,23 +20,27 @@ SpeedTestResult _$SpeedTestResultFromJson(Map json) { /// @nodoc mixin _$SpeedTestResult { + @JsonKey(fromJson: _toInt) int? get id => throw _privateConstructorUsedError; - @JsonKey(name: 'speed_test_id') + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? get speedTestId => throw _privateConstructorUsedError; @JsonKey(name: 'test_type') String? get testType => throw _privateConstructorUsedError; String? get source => throw _privateConstructorUsedError; String? get destination => throw _privateConstructorUsedError; + @JsonKey(fromJson: _toInt) int? get port => throw _privateConstructorUsedError; @JsonKey(name: 'iperf_protocol') String? get iperfProtocol => throw _privateConstructorUsedError; - @JsonKey(name: 'download_mbps') + @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? get downloadMbps => throw _privateConstructorUsedError; - @JsonKey(name: 'upload_mbps') + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? get uploadMbps => throw _privateConstructorUsedError; + @JsonKey(fromJson: _toDouble) double? get rtt => throw _privateConstructorUsedError; + @JsonKey(fromJson: _toDouble) double? get jitter => throw _privateConstructorUsedError; - @JsonKey(name: 'packet_loss') + @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? get packetLoss => throw _privateConstructorUsedError; bool get passed => throw _privateConstructorUsedError; @JsonKey(name: 'is_applicable') @@ -48,23 +52,23 @@ mixin _$SpeedTestResult { String? get raw => throw _privateConstructorUsedError; @JsonKey(name: 'image_url') String? get imageUrl => throw _privateConstructorUsedError; - @JsonKey(name: 'access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) int? get accessPointId => throw _privateConstructorUsedError; - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? get testedViaAccessPointId => throw _privateConstructorUsedError; - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? get testedViaAccessPointRadioId => throw _privateConstructorUsedError; - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? get testedViaMediaConverterId => throw _privateConstructorUsedError; - @JsonKey(name: 'uplink_id') + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? get uplinkId => throw _privateConstructorUsedError; - @JsonKey(name: 'wlan_id') + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? get wlanId => throw _privateConstructorUsedError; - @JsonKey(name: 'pms_room_id') + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? get pmsRoomId => throw _privateConstructorUsedError; @JsonKey(name: 'room_type') String? get roomType => throw _privateConstructorUsedError; - @JsonKey(name: 'admin_id') + @JsonKey(name: 'admin_id', fromJson: _toInt) int? get adminId => throw _privateConstructorUsedError; String? get note => throw _privateConstructorUsedError; String? get scratch => throw _privateConstructorUsedError; @@ -86,36 +90,40 @@ mixin _$SpeedTestResult { @optionalTypeArgs TResult when( TResult Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -132,36 +140,40 @@ mixin _$SpeedTestResult { @optionalTypeArgs TResult? whenOrNull( TResult? Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -178,36 +190,40 @@ mixin _$SpeedTestResult { @optionalTypeArgs TResult maybeWhen( TResult Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -251,35 +267,36 @@ abstract class $SpeedTestResultCopyWith<$Res> { _$SpeedTestResultCopyWithImpl<$Res, SpeedTestResult>; @useResult $Res call( - {int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + {@JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -505,35 +522,36 @@ abstract class _$$SpeedTestResultImplCopyWith<$Res> @override @useResult $Res call( - {int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + {@JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -752,35 +770,36 @@ class __$$SpeedTestResultImplCopyWithImpl<$Res> @JsonSerializable() class _$SpeedTestResultImpl extends _SpeedTestResult { const _$SpeedTestResultImpl( - {this.id, - @JsonKey(name: 'speed_test_id') this.speedTestId, + {@JsonKey(fromJson: _toInt) this.id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) this.speedTestId, @JsonKey(name: 'test_type') this.testType, this.source, this.destination, - this.port, + @JsonKey(fromJson: _toInt) this.port, @JsonKey(name: 'iperf_protocol') this.iperfProtocol, - @JsonKey(name: 'download_mbps') this.downloadMbps, - @JsonKey(name: 'upload_mbps') this.uploadMbps, - this.rtt, - this.jitter, - @JsonKey(name: 'packet_loss') this.packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) this.downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) this.uploadMbps, + @JsonKey(fromJson: _toDouble) this.rtt, + @JsonKey(fromJson: _toDouble) this.jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) this.packetLoss, this.passed = false, @JsonKey(name: 'is_applicable') this.isApplicable = true, @JsonKey(name: 'initiated_at') this.initiatedAt, @JsonKey(name: 'completed_at') this.completedAt, this.raw, @JsonKey(name: 'image_url') this.imageUrl, - @JsonKey(name: 'access_point_id') this.accessPointId, - @JsonKey(name: 'tested_via_access_point_id') this.testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) this.accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + this.testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) this.testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) this.testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') this.uplinkId, - @JsonKey(name: 'wlan_id') this.wlanId, - @JsonKey(name: 'pms_room_id') this.pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) this.uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) this.wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) this.pmsRoomId, @JsonKey(name: 'room_type') this.roomType, - @JsonKey(name: 'admin_id') this.adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) this.adminId, this.note, this.scratch, @JsonKey(name: 'created_by') this.createdBy, @@ -797,9 +816,10 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { _$$SpeedTestResultImplFromJson(json); @override + @JsonKey(fromJson: _toInt) final int? id; @override - @JsonKey(name: 'speed_test_id') + @JsonKey(name: 'speed_test_id', fromJson: _toInt) final int? speedTestId; @override @JsonKey(name: 'test_type') @@ -809,22 +829,25 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @override final String? destination; @override + @JsonKey(fromJson: _toInt) final int? port; @override @JsonKey(name: 'iperf_protocol') final String? iperfProtocol; @override - @JsonKey(name: 'download_mbps') + @JsonKey(name: 'download_mbps', fromJson: _toDouble) final double? downloadMbps; @override - @JsonKey(name: 'upload_mbps') + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) final double? uploadMbps; @override + @JsonKey(fromJson: _toDouble) final double? rtt; @override + @JsonKey(fromJson: _toDouble) final double? jitter; @override - @JsonKey(name: 'packet_loss') + @JsonKey(name: 'packet_loss', fromJson: _toDouble) final double? packetLoss; @override @JsonKey() @@ -844,31 +867,31 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @JsonKey(name: 'image_url') final String? imageUrl; @override - @JsonKey(name: 'access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) final int? accessPointId; @override - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) final int? testedViaAccessPointId; @override - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) final int? testedViaAccessPointRadioId; @override - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) final int? testedViaMediaConverterId; @override - @JsonKey(name: 'uplink_id') + @JsonKey(name: 'uplink_id', fromJson: _toInt) final int? uplinkId; @override - @JsonKey(name: 'wlan_id') + @JsonKey(name: 'wlan_id', fromJson: _toInt) final int? wlanId; @override - @JsonKey(name: 'pms_room_id') + @JsonKey(name: 'pms_room_id', fromJson: _toInt) final int? pmsRoomId; @override @JsonKey(name: 'room_type') final String? roomType; @override - @JsonKey(name: 'admin_id') + @JsonKey(name: 'admin_id', fromJson: _toInt) final int? adminId; @override final String? note; @@ -1031,36 +1054,40 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @optionalTypeArgs TResult when( TResult Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -1117,36 +1144,40 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @optionalTypeArgs TResult? whenOrNull( TResult? Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -1203,36 +1234,40 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { @optionalTypeArgs TResult maybeWhen( TResult Function( - int? id, - @JsonKey(name: 'speed_test_id') int? speedTestId, + @JsonKey(fromJson: _toInt) int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? speedTestId, @JsonKey(name: 'test_type') String? testType, String? source, String? destination, - int? port, + @JsonKey(fromJson: _toInt) int? port, @JsonKey(name: 'iperf_protocol') String? iperfProtocol, - @JsonKey(name: 'download_mbps') double? downloadMbps, - @JsonKey(name: 'upload_mbps') double? uploadMbps, - double? rtt, - double? jitter, - @JsonKey(name: 'packet_loss') double? packetLoss, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + double? uploadMbps, + @JsonKey(fromJson: _toDouble) double? rtt, + @JsonKey(fromJson: _toDouble) double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + double? packetLoss, bool passed, @JsonKey(name: 'is_applicable') bool isApplicable, @JsonKey(name: 'initiated_at') DateTime? initiatedAt, @JsonKey(name: 'completed_at') DateTime? completedAt, String? raw, @JsonKey(name: 'image_url') String? imageUrl, - @JsonKey(name: 'access_point_id') int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) + int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') int? uplinkId, - @JsonKey(name: 'wlan_id') int? wlanId, - @JsonKey(name: 'pms_room_id') int? pmsRoomId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? pmsRoomId, @JsonKey(name: 'room_type') String? roomType, - @JsonKey(name: 'admin_id') int? adminId, + @JsonKey(name: 'admin_id', fromJson: _toInt) int? adminId, String? note, String? scratch, @JsonKey(name: 'created_by') String? createdBy, @@ -1327,56 +1362,61 @@ class _$SpeedTestResultImpl extends _SpeedTestResult { abstract class _SpeedTestResult extends SpeedTestResult { const factory _SpeedTestResult( - {final int? id, - @JsonKey(name: 'speed_test_id') final int? speedTestId, - @JsonKey(name: 'test_type') final String? testType, - final String? source, - final String? destination, - final int? port, - @JsonKey(name: 'iperf_protocol') final String? iperfProtocol, - @JsonKey(name: 'download_mbps') final double? downloadMbps, - @JsonKey(name: 'upload_mbps') final double? uploadMbps, - final double? rtt, - final double? jitter, - @JsonKey(name: 'packet_loss') final double? packetLoss, - final bool passed, - @JsonKey(name: 'is_applicable') final bool isApplicable, - @JsonKey(name: 'initiated_at') final DateTime? initiatedAt, - @JsonKey(name: 'completed_at') final DateTime? completedAt, - final String? raw, - @JsonKey(name: 'image_url') final String? imageUrl, - @JsonKey(name: 'access_point_id') final int? accessPointId, - @JsonKey(name: 'tested_via_access_point_id') - final int? testedViaAccessPointId, - @JsonKey(name: 'tested_via_access_point_radio_id') - final int? testedViaAccessPointRadioId, - @JsonKey(name: 'tested_via_media_converter_id') - final int? testedViaMediaConverterId, - @JsonKey(name: 'uplink_id') final int? uplinkId, - @JsonKey(name: 'wlan_id') final int? wlanId, - @JsonKey(name: 'pms_room_id') final int? pmsRoomId, - @JsonKey(name: 'room_type') final String? roomType, - @JsonKey(name: 'admin_id') final int? adminId, - final String? note, - final String? scratch, - @JsonKey(name: 'created_by') final String? createdBy, - @JsonKey(name: 'updated_by') final String? updatedBy, - @JsonKey(name: 'created_at') final DateTime? createdAt, - @JsonKey(name: 'updated_at') final DateTime? updatedAt, - final bool hasError, - final String? errorMessage, - @JsonKey(name: 'local_ip_address') final String? localIpAddress, - @JsonKey(name: 'server_host') final String? serverHost}) = - _$SpeedTestResultImpl; + {@JsonKey(fromJson: _toInt) final int? id, + @JsonKey(name: 'speed_test_id', fromJson: _toInt) final int? speedTestId, + @JsonKey(name: 'test_type') final String? testType, + final String? source, + final String? destination, + @JsonKey(fromJson: _toInt) final int? port, + @JsonKey(name: 'iperf_protocol') final String? iperfProtocol, + @JsonKey(name: 'download_mbps', fromJson: _toDouble) + final double? downloadMbps, + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) + final double? uploadMbps, + @JsonKey(fromJson: _toDouble) final double? rtt, + @JsonKey(fromJson: _toDouble) final double? jitter, + @JsonKey(name: 'packet_loss', fromJson: _toDouble) + final double? packetLoss, + final bool passed, + @JsonKey(name: 'is_applicable') final bool isApplicable, + @JsonKey(name: 'initiated_at') final DateTime? initiatedAt, + @JsonKey(name: 'completed_at') final DateTime? completedAt, + final String? raw, + @JsonKey(name: 'image_url') final String? imageUrl, + @JsonKey(name: 'access_point_id', fromJson: _toInt) + final int? accessPointId, + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) + final int? testedViaAccessPointId, + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) + final int? testedViaAccessPointRadioId, + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) + final int? testedViaMediaConverterId, + @JsonKey(name: 'uplink_id', fromJson: _toInt) final int? uplinkId, + @JsonKey(name: 'wlan_id', fromJson: _toInt) final int? wlanId, + @JsonKey(name: 'pms_room_id', fromJson: _toInt) final int? pmsRoomId, + @JsonKey(name: 'room_type') final String? roomType, + @JsonKey(name: 'admin_id', fromJson: _toInt) final int? adminId, + final String? note, + final String? scratch, + @JsonKey(name: 'created_by') final String? createdBy, + @JsonKey(name: 'updated_by') final String? updatedBy, + @JsonKey(name: 'created_at') final DateTime? createdAt, + @JsonKey(name: 'updated_at') final DateTime? updatedAt, + final bool hasError, + final String? errorMessage, + @JsonKey(name: 'local_ip_address') final String? localIpAddress, + @JsonKey(name: 'server_host') + final String? serverHost}) = _$SpeedTestResultImpl; const _SpeedTestResult._() : super._(); factory _SpeedTestResult.fromJson(Map json) = _$SpeedTestResultImpl.fromJson; @override + @JsonKey(fromJson: _toInt) int? get id; @override - @JsonKey(name: 'speed_test_id') + @JsonKey(name: 'speed_test_id', fromJson: _toInt) int? get speedTestId; @override @JsonKey(name: 'test_type') @@ -1386,22 +1426,25 @@ abstract class _SpeedTestResult extends SpeedTestResult { @override String? get destination; @override + @JsonKey(fromJson: _toInt) int? get port; @override @JsonKey(name: 'iperf_protocol') String? get iperfProtocol; @override - @JsonKey(name: 'download_mbps') + @JsonKey(name: 'download_mbps', fromJson: _toDouble) double? get downloadMbps; @override - @JsonKey(name: 'upload_mbps') + @JsonKey(name: 'upload_mbps', fromJson: _toDouble) double? get uploadMbps; @override + @JsonKey(fromJson: _toDouble) double? get rtt; @override + @JsonKey(fromJson: _toDouble) double? get jitter; @override - @JsonKey(name: 'packet_loss') + @JsonKey(name: 'packet_loss', fromJson: _toDouble) double? get packetLoss; @override bool get passed; @@ -1420,31 +1463,31 @@ abstract class _SpeedTestResult extends SpeedTestResult { @JsonKey(name: 'image_url') String? get imageUrl; @override - @JsonKey(name: 'access_point_id') + @JsonKey(name: 'access_point_id', fromJson: _toInt) int? get accessPointId; @override - @JsonKey(name: 'tested_via_access_point_id') + @JsonKey(name: 'tested_via_access_point_id', fromJson: _toInt) int? get testedViaAccessPointId; @override - @JsonKey(name: 'tested_via_access_point_radio_id') + @JsonKey(name: 'tested_via_access_point_radio_id', fromJson: _toInt) int? get testedViaAccessPointRadioId; @override - @JsonKey(name: 'tested_via_media_converter_id') + @JsonKey(name: 'tested_via_media_converter_id', fromJson: _toInt) int? get testedViaMediaConverterId; @override - @JsonKey(name: 'uplink_id') + @JsonKey(name: 'uplink_id', fromJson: _toInt) int? get uplinkId; @override - @JsonKey(name: 'wlan_id') + @JsonKey(name: 'wlan_id', fromJson: _toInt) int? get wlanId; @override - @JsonKey(name: 'pms_room_id') + @JsonKey(name: 'pms_room_id', fromJson: _toInt) int? get pmsRoomId; @override @JsonKey(name: 'room_type') String? get roomType; @override - @JsonKey(name: 'admin_id') + @JsonKey(name: 'admin_id', fromJson: _toInt) int? get adminId; @override String? get note; diff --git a/lib/features/speed_test/domain/entities/speed_test_result.g.dart b/lib/features/speed_test/domain/entities/speed_test_result.g.dart index b75fb75..2304093 100644 --- a/lib/features/speed_test/domain/entities/speed_test_result.g.dart +++ b/lib/features/speed_test/domain/entities/speed_test_result.g.dart @@ -9,18 +9,18 @@ part of 'speed_test_result.dart'; _$SpeedTestResultImpl _$$SpeedTestResultImplFromJson( Map json) => _$SpeedTestResultImpl( - id: (json['id'] as num?)?.toInt(), - speedTestId: (json['speed_test_id'] as num?)?.toInt(), + id: _toInt(json['id']), + speedTestId: _toInt(json['speed_test_id']), testType: json['test_type'] as String?, source: json['source'] as String?, destination: json['destination'] as String?, - port: (json['port'] as num?)?.toInt(), + port: _toInt(json['port']), iperfProtocol: json['iperf_protocol'] as String?, - downloadMbps: (json['download_mbps'] as num?)?.toDouble(), - uploadMbps: (json['upload_mbps'] as num?)?.toDouble(), - rtt: (json['rtt'] as num?)?.toDouble(), - jitter: (json['jitter'] as num?)?.toDouble(), - packetLoss: (json['packet_loss'] as num?)?.toDouble(), + downloadMbps: _toDouble(json['download_mbps']), + uploadMbps: _toDouble(json['upload_mbps']), + rtt: _toDouble(json['rtt']), + jitter: _toDouble(json['jitter']), + packetLoss: _toDouble(json['packet_loss']), passed: json['passed'] as bool? ?? false, isApplicable: json['is_applicable'] as bool? ?? true, initiatedAt: json['initiated_at'] == null @@ -31,18 +31,16 @@ _$SpeedTestResultImpl _$$SpeedTestResultImplFromJson( : DateTime.parse(json['completed_at'] as String), raw: json['raw'] as String?, imageUrl: json['image_url'] as String?, - accessPointId: (json['access_point_id'] as num?)?.toInt(), - testedViaAccessPointId: - (json['tested_via_access_point_id'] as num?)?.toInt(), + accessPointId: _toInt(json['access_point_id']), + testedViaAccessPointId: _toInt(json['tested_via_access_point_id']), testedViaAccessPointRadioId: - (json['tested_via_access_point_radio_id'] as num?)?.toInt(), - testedViaMediaConverterId: - (json['tested_via_media_converter_id'] as num?)?.toInt(), - uplinkId: (json['uplink_id'] as num?)?.toInt(), - wlanId: (json['wlan_id'] as num?)?.toInt(), - pmsRoomId: (json['pms_room_id'] as num?)?.toInt(), + _toInt(json['tested_via_access_point_radio_id']), + testedViaMediaConverterId: _toInt(json['tested_via_media_converter_id']), + uplinkId: _toInt(json['uplink_id']), + wlanId: _toInt(json['wlan_id']), + pmsRoomId: _toInt(json['pms_room_id']), roomType: json['room_type'] as String?, - adminId: (json['admin_id'] as num?)?.toInt(), + adminId: _toInt(json['admin_id']), note: json['note'] as String?, scratch: json['scratch'] as String?, createdBy: json['created_by'] as String?, diff --git a/lib/features/speed_test/presentation/widgets/speed_test_card.dart b/lib/features/speed_test/presentation/widgets/speed_test_card.dart index 0ebeda0..df6c4e0 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_card.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_card.dart @@ -2,12 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; -import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; +import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; -import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; class SpeedTestCard extends ConsumerStatefulWidget { @@ -140,21 +139,21 @@ class _SpeedTestCardState extends ConsumerState { builder: (BuildContext context) { return SpeedTestPopup( cachedTest: adhocConfig, - onCompleted: () async { + onCompleted: () { if (mounted) { LoggerService.info( - 'Speed test completed - reloading result for dashboard', - tag: 'SpeedTestCard'); - + 'Speed test completed - reloading result for dashboard', + tag: 'SpeedTestCard', + ); final result = _speedTestService.lastResult; setState(() { _lastResult = result; }); - - // Submit adhoc result to server if test completed successfully - if (result != null && !result.hasError) { - await _submitAdhocResult(result, adhocConfig?.id); - } + } + }, + onResultSubmitted: (result) async { + if (!result.hasError) { + await _submitAdhocResult(result); } }, ); @@ -162,61 +161,34 @@ class _SpeedTestCardState extends ConsumerState { ); } - /// Submit adhoc speed test result to the server - Future _submitAdhocResult(SpeedTestResult result, int? configId) async { + /// Submit adhoc speed test result to the server via WebSocket cache integration + Future _submitAdhocResult(SpeedTestResult result) async { try { LoggerService.info( 'Submitting adhoc speed test result: ' - 'source=${result.localIpAddress}, ' - 'destination=${result.serverHost}, ' + 'source=${result.source}, ' + 'destination=${result.destination}, ' 'download=${result.downloadMbps}, ' 'upload=${result.uploadMbps}, ' 'ping=${result.rtt}', tag: 'SpeedTestCard', ); - // Check if requirements are met (for pass/fail determination) - bool passed = true; - if (configId != null) { - final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); - final configs = cacheIntegration.getCachedSpeedTestConfigs(); - final config = configs.where((c) => c.id == configId).firstOrNull; - - if (config != null) { - final downloadOk = config.minDownloadMbps == null || - (result.downloadMbps ?? 0) >= config.minDownloadMbps!; - final uploadOk = config.minUploadMbps == null || - (result.uploadMbps ?? 0) >= config.minUploadMbps!; - passed = downloadOk && uploadOk; - } - } - - // Create result with all required fields for submission - final resultToSubmit = SpeedTestResult( - speedTestId: configId, - testType: 'iperf3', - source: result.localIpAddress, - destination: result.serverHost, - port: _speedTestService.serverPort, - iperfProtocol: _speedTestService.useUdp ? 'udp' : 'tcp', - downloadMbps: result.downloadMbps, - uploadMbps: result.uploadMbps, - rtt: result.rtt, - jitter: result.jitter, - passed: passed, - completedAt: DateTime.now(), - localIpAddress: result.localIpAddress, - serverHost: result.serverHost, + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + final success = await cacheIntegration.createAdhocSpeedTestResult( + downloadSpeed: result.downloadMbps ?? 0, + uploadSpeed: result.uploadMbps ?? 0, + latency: result.rtt ?? 0, + source: result.source, + destination: result.destination, + port: result.port, + protocol: result.iperfProtocol, + passed: result.passed, ); - // Submit via provider - final saved = await ref - .read(speedTestResultsNotifierProvider().notifier) - .createResult(resultToSubmit); - - if (saved != null) { + if (success) { LoggerService.info( - 'Adhoc speed test result submitted successfully: id=${saved.id}', + 'Adhoc speed test result submitted successfully', tag: 'SpeedTestCard', ); } else { diff --git a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart index cf34c84..7efa500 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart @@ -19,11 +19,15 @@ class SpeedTestPopup extends StatefulWidget { final VoidCallback? onCompleted; + /// Callback when result should be submitted (auto-called when test passes) + final void Function(SpeedTestResult result)? onResultSubmitted; + const SpeedTestPopup({ super.key, this.cachedTest, this.speedTestWithResults, this.onCompleted, + this.onResultSubmitted, }) : assert( cachedTest != null || speedTestWithResults != null || true, 'Either cachedTest or speedTestWithResults can be provided, or neither for a standalone test', @@ -256,16 +260,47 @@ class _SpeedTestPopupState extends State if (config == null) { _testPassed = true; - return; + } else { + final minDownload = _getMinDownload(); + final minUpload = _getMinUpload(); + + final downloadPassed = minDownload == null || _downloadSpeed >= minDownload; + final uploadPassed = minUpload == null || _uploadSpeed >= minUpload; + + _testPassed = downloadPassed && uploadPassed; } - final minDownload = _getMinDownload(); - final minUpload = _getMinUpload(); + // Auto-submit result when test completes (passed or failed) + if (widget.onResultSubmitted != null) { + _submitResult(); + } + } + + /// Submit the test result via callback + void _submitResult() { + final result = SpeedTestResult( + downloadMbps: _downloadSpeed, + uploadMbps: _uploadSpeed, + rtt: _latency, + localIpAddress: _localIp, + serverHost: _serverHost, + speedTestId: _effectiveConfig?.id, + passed: _testPassed, + completedAt: DateTime.now(), + testType: 'iperf3', + source: _localIp, + destination: _serverHost, + port: _speedTestService.serverPort, + iperfProtocol: _speedTestService.useUdp ? 'udp' : 'tcp', + ); - final downloadPassed = minDownload == null || _downloadSpeed >= minDownload; - final uploadPassed = minUpload == null || _uploadSpeed >= minUpload; + LoggerService.info( + 'SpeedTestPopup: Auto-submitting result - passed=$_testPassed, ' + 'download=$_downloadSpeed, upload=$_uploadSpeed', + tag: 'SpeedTestPopup', + ); - _testPassed = downloadPassed && uploadPassed; + widget.onResultSubmitted?.call(result); } @override From 66973452c0ec91c99628dfa2b90ad7c2032d0981 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Wed, 21 Jan 2026 13:58:54 -0800 Subject: [PATCH 23/24] Add pms speed test --- .../widgets/room_speed_test_selector.dart | 185 +++--------------- pubspec.yaml | 3 + 2 files changed, 34 insertions(+), 154 deletions(-) diff --git a/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart b/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart index 6f30f25..d3f0141 100644 --- a/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart +++ b/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart @@ -5,7 +5,6 @@ import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; -import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; /// Helper class to group test configuration with its results @@ -45,7 +44,6 @@ class RoomSpeedTestSelector extends ConsumerStatefulWidget { class _RoomSpeedTestSelectorState extends ConsumerState { List _speedTests = []; bool _isLoading = true; - bool _isUpdating = false; String? _errorMessage; SpeedTestResult? _selectedResult; @@ -193,81 +191,6 @@ class _RoomSpeedTestSelectorState extends ConsumerState { return result.passed || (downloadPass && uploadPass); } - Future _toggleApplicable(SpeedTestResult result) async { - if (_isUpdating) return; - - setState(() { - _isUpdating = true; - }); - - try { - // Create updated result with toggled isApplicable - final updatedResult = result.copyWith( - isApplicable: !result.isApplicable, - ); - - LoggerService.info( - 'Toggling isApplicable for result ${result.id}: ' - '${result.isApplicable} -> ${!result.isApplicable}', - tag: 'RoomSpeedTestSelector', - ); - - // Update via provider - final notifier = ref.read( - speedTestResultsNotifierProvider( - speedTestId: result.speedTestId, - ).notifier, - ); - - final updated = await notifier.updateResult(updatedResult); - - if (updated != null) { - // Update the cache so _loadSpeedTests sees the new value - final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); - cacheIntegration.updateSpeedTestResultInCache(updated); - - // Update the selected result locally - setState(() { - _selectedResult = updated; - }); - // Reload to get fresh data from cache - await _loadSpeedTests(); - } else { - LoggerService.error( - 'Failed to update result ${result.id}', - tag: 'RoomSpeedTestSelector', - ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to update result'), - backgroundColor: AppColors.error, - ), - ); - } - } - } catch (e) { - LoggerService.error( - 'Error toggling isApplicable: $e', - tag: 'RoomSpeedTestSelector', - ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: AppColors.error, - ), - ); - } - } finally { - if (mounted) { - setState(() { - _isUpdating = false; - }); - } - } - } - @override Widget build(BuildContext context) { if (_isLoading) { @@ -494,42 +417,39 @@ class _RoomSpeedTestSelectorState extends ConsumerState { const SizedBox(height: 16), _buildResultDetails(currentResult, selectedConfig), - // Run test button (hidden when result is not applicable) - if (currentResult.isApplicable) ...[ - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - int? testedViaAccessPointId = - currentResult.testedViaAccessPointId; - if (testedViaAccessPointId == null && - widget.apIds.isNotEmpty) { - testedViaAccessPointId = widget.apIds.first; - } - - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => SpeedTestPopup( - cachedTest: selectedConfig, - existingResult: currentResult, - apId: testedViaAccessPointId, - onCompleted: () { - _loadSpeedTests(); - }, - ), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: const Text('Run Test'), + const SizedBox(height: 16), + + // Run test button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + int? testedViaAccessPointId = + currentResult.testedViaAccessPointId; + if (testedViaAccessPointId == null && + widget.apIds.isNotEmpty) { + testedViaAccessPointId = widget.apIds.first; + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => SpeedTestPopup( + cachedTest: selectedConfig, + onCompleted: () { + _loadSpeedTests(); + }, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), ), + child: const Text('Run Test'), ), - ], + ), ], ), ), @@ -911,49 +831,6 @@ class _RoomSpeedTestSelectorState extends ConsumerState { ), ), ], - - // Toggle applicable button - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton.icon( - onPressed: _isUpdating ? null : () => _toggleApplicable(result), - icon: _isUpdating - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.textSecondary, - ), - ) - : Icon( - result.isApplicable - ? Icons.block - : Icons.check_circle_outline, - size: 18, - color: AppColors.textSecondary, - ), - label: Text( - result.isApplicable - ? 'Mark as Not Applicable' - : 'Mark as Applicable', - style: TextStyle( - fontSize: 12, - color: AppColors.textSecondary, - ), - ), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - side: BorderSide(color: AppColors.gray600), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(6)), - ), - ), - ), - ], - ), ], ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 1403c12..4b2525b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -136,6 +136,9 @@ flutter: - assets/config/onboarding_messages.json >>>>>>> 6a559fa (Draft for device onboarding) + # Speed test indicator images + - assets/speed_test_indicator_img/ + # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images From 88d756f52e64150604b2f842238de9117adcd525 Mon Sep 17 00:00:00 2001 From: Dominic Pham Date: Wed, 21 Jan 2026 15:24:47 -0800 Subject: [PATCH 24/24] Uplink added --- .../models/device_model_sealed.freezed.dart | 61 +- .../data/models/device_model_sealed.g.dart | 2 + .../widgets/device_speed_test_section.dart | 28 +- .../widgets/room_speed_test_selector.dart | 185 ++++- .../widgets/speed_test_popup.dart | 743 ++++++++++-------- 5 files changed, 657 insertions(+), 362 deletions(-) diff --git a/lib/features/devices/data/models/device_model_sealed.freezed.dart b/lib/features/devices/data/models/device_model_sealed.freezed.dart index ca159c2..5f63b4d 100644 --- a/lib/features/devices/data/models/device_model_sealed.freezed.dart +++ b/lib/features/devices/data/models/device_model_sealed.freezed.dart @@ -33,7 +33,6 @@ DeviceModelSealed _$DeviceModelSealedFromJson(Map json) { /// @nodoc mixin _$DeviceModelSealed { -// Common fields String get id => throw _privateConstructorUsedError; String get name => throw _privateConstructorUsedError; String get status => throw _privateConstructorUsedError; @@ -81,6 +80,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -198,6 +198,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -315,6 +316,7 @@ mixin _$DeviceModelSealed { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -626,6 +628,7 @@ abstract class _$$APModelImplCopyWith<$Res> List? images, @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -672,6 +675,7 @@ class __$$APModelImplCopyWithImpl<$Res> Object? images = freezed, Object? healthNotices = freezed, Object? hnCounts = freezed, + Object? infrastructureLinkId = freezed, Object? connectionState = freezed, Object? signalStrength = freezed, Object? connectedClients = freezed, @@ -751,6 +755,10 @@ class __$$APModelImplCopyWithImpl<$Res> ? _value.hnCounts : hnCounts // ignore: cast_nullable_to_non_nullable as HealthCountsModel?, + infrastructureLinkId: freezed == infrastructureLinkId + ? _value.infrastructureLinkId + : infrastructureLinkId // ignore: cast_nullable_to_non_nullable + as int?, connectionState: freezed == connectionState ? _value.connectionState : connectionState // ignore: cast_nullable_to_non_nullable @@ -826,6 +834,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') this.hnCounts, + @JsonKey(name: 'infrastructure_link_id') this.infrastructureLinkId, @JsonKey(name: 'connection_state') this.connectionState, @JsonKey(name: 'signal_strength') this.signalStrength, @JsonKey(name: 'connected_clients') this.connectedClients, @@ -845,7 +854,6 @@ class _$APModelImpl extends APModel { factory _$APModelImpl.fromJson(Map json) => _$$APModelImplFromJson(json); -// Common fields @override final String id; @override @@ -912,7 +920,9 @@ class _$APModelImpl extends APModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; -// AP-specific fields + @override + @JsonKey(name: 'infrastructure_link_id') + final int? infrastructureLinkId; @override @JsonKey(name: 'connection_state') final String? connectionState; @@ -944,7 +954,7 @@ class _$APModelImpl extends APModel { @override String toString() { - return 'DeviceModelSealed.ap(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, healthNotices: $healthNotices, hnCounts: $hnCounts, connectionState: $connectionState, signalStrength: $signalStrength, connectedClients: $connectedClients, ssid: $ssid, channel: $channel, maxClients: $maxClients, currentUpload: $currentUpload, currentDownload: $currentDownload, onboardingStatus: $onboardingStatus)'; + return 'DeviceModelSealed.ap(id: $id, name: $name, status: $status, pmsRoom: $pmsRoom, pmsRoomId: $pmsRoomId, ipAddress: $ipAddress, macAddress: $macAddress, location: $location, lastSeen: $lastSeen, metadata: $metadata, model: $model, serialNumber: $serialNumber, firmware: $firmware, note: $note, images: $images, healthNotices: $healthNotices, hnCounts: $hnCounts, infrastructureLinkId: $infrastructureLinkId, connectionState: $connectionState, signalStrength: $signalStrength, connectedClients: $connectedClients, ssid: $ssid, channel: $channel, maxClients: $maxClients, currentUpload: $currentUpload, currentDownload: $currentDownload, onboardingStatus: $onboardingStatus)'; } @override @@ -978,6 +988,8 @@ class _$APModelImpl extends APModel { .equals(other._healthNotices, _healthNotices) && (identical(other.hnCounts, hnCounts) || other.hnCounts == hnCounts) && + (identical(other.infrastructureLinkId, infrastructureLinkId) || + other.infrastructureLinkId == infrastructureLinkId) && (identical(other.connectionState, connectionState) || other.connectionState == connectionState) && (identical(other.signalStrength, signalStrength) || @@ -1017,6 +1029,7 @@ class _$APModelImpl extends APModel { const DeepCollectionEquality().hash(_images), const DeepCollectionEquality().hash(_healthNotices), hnCounts, + infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1056,6 +1069,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -1169,6 +1183,7 @@ class _$APModelImpl extends APModel { images, healthNotices, hnCounts, + infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1202,6 +1217,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -1315,6 +1331,7 @@ class _$APModelImpl extends APModel { images, healthNotices, hnCounts, + infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1348,6 +1365,7 @@ class _$APModelImpl extends APModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -1463,6 +1481,7 @@ class _$APModelImpl extends APModel { images, healthNotices, hnCounts, + infrastructureLinkId, connectionState, signalStrength, connectedClients, @@ -1541,6 +1560,7 @@ abstract class APModel extends DeviceModelSealed { @JsonKey(name: 'health_notices') final List? healthNotices, @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') final int? infrastructureLinkId, @JsonKey(name: 'connection_state') final String? connectionState, @JsonKey(name: 'signal_strength') final int? signalStrength, @JsonKey(name: 'connected_clients') final int? connectedClients, @@ -1555,7 +1575,7 @@ abstract class APModel extends DeviceModelSealed { factory APModel.fromJson(Map json) = _$APModelImpl.fromJson; - @override // Common fields + @override String get id; @override String get name; @@ -1596,7 +1616,9 @@ abstract class APModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; // AP-specific fields + HealthCountsModel? get hnCounts; + @JsonKey(name: 'infrastructure_link_id') + int? get infrastructureLinkId; @JsonKey(name: 'connection_state') String? get connectionState; @JsonKey(name: 'signal_strength') @@ -1845,7 +1867,6 @@ class _$ONTModelImpl extends ONTModel { factory _$ONTModelImpl.fromJson(Map json) => _$$ONTModelImplFromJson(json); -// Common fields @override final String id; @override @@ -1912,7 +1933,6 @@ class _$ONTModelImpl extends ONTModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; -// ONT-specific fields @override @JsonKey(name: 'is_registered') final bool? isRegistered; @@ -2053,6 +2073,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -2196,6 +2217,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -2339,6 +2361,7 @@ class _$ONTModelImpl extends ONTModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -2541,7 +2564,7 @@ abstract class ONTModel extends DeviceModelSealed { factory ONTModel.fromJson(Map json) = _$ONTModelImpl.fromJson; - @override // Common fields + @override String get id; @override String get name; @@ -2582,7 +2605,7 @@ abstract class ONTModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; // ONT-specific fields + HealthCountsModel? get hnCounts; @JsonKey(name: 'is_registered') bool? get isRegistered; @JsonKey(name: 'switch_port') @@ -2817,7 +2840,6 @@ class _$SwitchModelImpl extends SwitchModel { factory _$SwitchModelImpl.fromJson(Map json) => _$$SwitchModelImplFromJson(json); -// Common fields @override final String id; @override @@ -2884,7 +2906,6 @@ class _$SwitchModelImpl extends SwitchModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; -// Switch-specific fields @override final String? host; final List>? _ports; @@ -3024,6 +3045,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -3168,6 +3190,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -3312,6 +3335,7 @@ class _$SwitchModelImpl extends SwitchModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -3516,7 +3540,7 @@ abstract class SwitchModel extends DeviceModelSealed { factory SwitchModel.fromJson(Map json) = _$SwitchModelImpl.fromJson; - @override // Common fields + @override String get id; @override String get name; @@ -3557,7 +3581,7 @@ abstract class SwitchModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; // Switch-specific fields + HealthCountsModel? get hnCounts; String? get host; @JsonKey(name: 'switch_ports') List>? get ports; @@ -3799,7 +3823,6 @@ class _$WLANModelImpl extends WLANModel { factory _$WLANModelImpl.fromJson(Map json) => _$$WLANModelImplFromJson(json); -// Common fields @override final String id; @override @@ -3866,7 +3889,6 @@ class _$WLANModelImpl extends WLANModel { @override @JsonKey(name: 'hn_counts') final HealthCountsModel? hnCounts; -// WLAN-specific fields @override @JsonKey(name: 'controller_type') final String? controllerType; @@ -4004,6 +4026,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -4149,6 +4172,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -4294,6 +4318,7 @@ class _$WLANModelImpl extends WLANModel { @JsonKey(name: 'health_notices') List? healthNotices, @JsonKey(name: 'hn_counts') HealthCountsModel? hnCounts, + @JsonKey(name: 'infrastructure_link_id') int? infrastructureLinkId, @JsonKey(name: 'connection_state') String? connectionState, @JsonKey(name: 'signal_strength') int? signalStrength, @JsonKey(name: 'connected_clients') int? connectedClients, @@ -4500,7 +4525,7 @@ abstract class WLANModel extends DeviceModelSealed { factory WLANModel.fromJson(Map json) = _$WLANModelImpl.fromJson; - @override // Common fields + @override String get id; @override String get name; @@ -4541,7 +4566,7 @@ abstract class WLANModel extends DeviceModelSealed { List? get healthNotices; @override @JsonKey(name: 'hn_counts') - HealthCountsModel? get hnCounts; // WLAN-specific fields + HealthCountsModel? get hnCounts; @JsonKey(name: 'controller_type') String? get controllerType; @JsonKey(name: 'managed_aps') diff --git a/lib/features/devices/data/models/device_model_sealed.g.dart b/lib/features/devices/data/models/device_model_sealed.g.dart index da0ac4e..31ac58a 100644 --- a/lib/features/devices/data/models/device_model_sealed.g.dart +++ b/lib/features/devices/data/models/device_model_sealed.g.dart @@ -35,6 +35,7 @@ _$APModelImpl _$$APModelImplFromJson(Map json) => ? null : HealthCountsModel.fromJson( json['hn_counts'] as Map), + infrastructureLinkId: (json['infrastructure_link_id'] as num?)?.toInt(), connectionState: json['connection_state'] as String?, signalStrength: (json['signal_strength'] as num?)?.toInt(), connectedClients: (json['connected_clients'] as num?)?.toInt(), @@ -78,6 +79,7 @@ Map _$$APModelImplToJson(_$APModelImpl instance) { writeNotNull('health_notices', instance.healthNotices?.map((e) => e.toJson()).toList()); writeNotNull('hn_counts', instance.hnCounts?.toJson()); + writeNotNull('infrastructure_link_id', instance.infrastructureLinkId); writeNotNull('connection_state', instance.connectionState); writeNotNull('signal_strength', instance.signalStrength); writeNotNull('connected_clients', instance.connectedClients); diff --git a/lib/features/devices/presentation/widgets/device_speed_test_section.dart b/lib/features/devices/presentation/widgets/device_speed_test_section.dart index bbba3fb..609d742 100644 --- a/lib/features/devices/presentation/widgets/device_speed_test_section.dart +++ b/lib/features/devices/presentation/widgets/device_speed_test_section.dart @@ -6,6 +6,7 @@ import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/core/widgets/widgets.dart'; import 'package:rgnets_fdk/features/devices/domain/constants/device_types.dart'; import 'package:rgnets_fdk/features/devices/domain/entities/device.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; @@ -112,13 +113,27 @@ class _DeviceSpeedTestSectionState Future _runSpeedTest() async { if (!mounted) return; - // Get adhoc config from cache final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); - final adhocConfig = cacheIntegration.getAdhocSpeedTestConfig(); - if (adhocConfig != null) { + // Try to get config from the device's existing results (uses the same test config) + SpeedTestConfig? config; + if (_deviceResults.isNotEmpty) { + final speedTestId = _deviceResults.first.speedTestId; + config = cacheIntegration.getSpeedTestConfigById(speedTestId); + if (config != null) { + LoggerService.info( + 'Running speed test for device ${_getPrefixedDeviceId()} with config from result: ${config.name} (id: $speedTestId)', + tag: 'DeviceSpeedTestSection', + ); + } + } + + // Fall back to adhoc config if no matching config found + config ??= cacheIntegration.getAdhocSpeedTestConfig(); + + if (config != null) { LoggerService.info( - 'Running speed test for device ${_getPrefixedDeviceId()} with adhoc config: ${adhocConfig.name}', + 'Running speed test for device ${_getPrefixedDeviceId()} with config: ${config.name}', tag: 'DeviceSpeedTestSection', ); } @@ -128,7 +143,10 @@ class _DeviceSpeedTestSectionState barrierDismissible: true, builder: (BuildContext context) { return SpeedTestPopup( - cachedTest: adhocConfig, + cachedTest: config, + apId: widget.device.type == DeviceTypes.accessPoint + ? _getNumericDeviceId() + : null, onCompleted: () { if (mounted) { // Reload results after test completion diff --git a/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart b/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart index d3f0141..6f30f25 100644 --- a/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart +++ b/lib/features/speed_test/presentation/widgets/room_speed_test_selector.dart @@ -5,6 +5,7 @@ import 'package:rgnets_fdk/core/services/logger_service.dart'; import 'package:rgnets_fdk/core/theme/app_colors.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; +import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; import 'package:rgnets_fdk/features/speed_test/presentation/widgets/speed_test_popup.dart'; /// Helper class to group test configuration with its results @@ -44,6 +45,7 @@ class RoomSpeedTestSelector extends ConsumerStatefulWidget { class _RoomSpeedTestSelectorState extends ConsumerState { List _speedTests = []; bool _isLoading = true; + bool _isUpdating = false; String? _errorMessage; SpeedTestResult? _selectedResult; @@ -191,6 +193,81 @@ class _RoomSpeedTestSelectorState extends ConsumerState { return result.passed || (downloadPass && uploadPass); } + Future _toggleApplicable(SpeedTestResult result) async { + if (_isUpdating) return; + + setState(() { + _isUpdating = true; + }); + + try { + // Create updated result with toggled isApplicable + final updatedResult = result.copyWith( + isApplicable: !result.isApplicable, + ); + + LoggerService.info( + 'Toggling isApplicable for result ${result.id}: ' + '${result.isApplicable} -> ${!result.isApplicable}', + tag: 'RoomSpeedTestSelector', + ); + + // Update via provider + final notifier = ref.read( + speedTestResultsNotifierProvider( + speedTestId: result.speedTestId, + ).notifier, + ); + + final updated = await notifier.updateResult(updatedResult); + + if (updated != null) { + // Update the cache so _loadSpeedTests sees the new value + final cacheIntegration = ref.read(webSocketCacheIntegrationProvider); + cacheIntegration.updateSpeedTestResultInCache(updated); + + // Update the selected result locally + setState(() { + _selectedResult = updated; + }); + // Reload to get fresh data from cache + await _loadSpeedTests(); + } else { + LoggerService.error( + 'Failed to update result ${result.id}', + tag: 'RoomSpeedTestSelector', + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update result'), + backgroundColor: AppColors.error, + ), + ); + } + } + } catch (e) { + LoggerService.error( + 'Error toggling isApplicable: $e', + tag: 'RoomSpeedTestSelector', + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: AppColors.error, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isUpdating = false; + }); + } + } + } + @override Widget build(BuildContext context) { if (_isLoading) { @@ -417,39 +494,42 @@ class _RoomSpeedTestSelectorState extends ConsumerState { const SizedBox(height: 16), _buildResultDetails(currentResult, selectedConfig), - const SizedBox(height: 16), - - // Run test button - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - int? testedViaAccessPointId = - currentResult.testedViaAccessPointId; - if (testedViaAccessPointId == null && - widget.apIds.isNotEmpty) { - testedViaAccessPointId = widget.apIds.first; - } - - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => SpeedTestPopup( - cachedTest: selectedConfig, - onCompleted: () { - _loadSpeedTests(); - }, - ), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), + // Run test button (hidden when result is not applicable) + if (currentResult.isApplicable) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + int? testedViaAccessPointId = + currentResult.testedViaAccessPointId; + if (testedViaAccessPointId == null && + widget.apIds.isNotEmpty) { + testedViaAccessPointId = widget.apIds.first; + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => SpeedTestPopup( + cachedTest: selectedConfig, + existingResult: currentResult, + apId: testedViaAccessPointId, + onCompleted: () { + _loadSpeedTests(); + }, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Run Test'), ), - child: const Text('Run Test'), ), - ), + ], ], ), ), @@ -831,6 +911,49 @@ class _RoomSpeedTestSelectorState extends ConsumerState { ), ), ], + + // Toggle applicable button + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + onPressed: _isUpdating ? null : () => _toggleApplicable(result), + icon: _isUpdating + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textSecondary, + ), + ) + : Icon( + result.isApplicable + ? Icons.block + : Icons.check_circle_outline, + size: 18, + color: AppColors.textSecondary, + ), + label: Text( + result.isApplicable + ? 'Mark as Not Applicable' + : 'Mark as Applicable', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + side: BorderSide(color: AppColors.gray600), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + ), + ), + ], + ), ], ), ); diff --git a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart index 7efa500..fc4fdaf 100644 --- a/lib/features/speed_test/presentation/widgets/speed_test_popup.dart +++ b/lib/features/speed_test/presentation/widgets/speed_test_popup.dart @@ -1,72 +1,58 @@ -import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:rgnets_fdk/core/theme/app_colors.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:rgnets_fdk/core/providers/websocket_providers.dart'; import 'package:rgnets_fdk/core/services/logger_service.dart'; -import 'package:rgnets_fdk/features/speed_test/data/services/speed_test_service.dart'; -import 'package:rgnets_fdk/features/speed_test/data/services/network_gateway_service.dart'; +import 'package:rgnets_fdk/core/theme/app_colors.dart'; +import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_result.dart'; import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_status.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_config.dart'; -import 'package:rgnets_fdk/features/speed_test/domain/entities/speed_test_with_results.dart'; +import 'package:rgnets_fdk/features/speed_test/presentation/providers/speed_test_providers.dart'; -class SpeedTestPopup extends StatefulWidget { - /// The speed test configuration (use this OR [speedTestWithResults]) +class SpeedTestPopup extends ConsumerStatefulWidget { + /// The speed test configuration final SpeedTestConfig? cachedTest; - /// The joined speed test with results (use this OR [cachedTest]) - /// If provided, the config will be extracted from this - final SpeedTestWithResults? speedTestWithResults; - final VoidCallback? onCompleted; /// Callback when result should be submitted (auto-called when test passes) final void Function(SpeedTestResult result)? onResultSubmitted; + /// Existing result to update (instead of creating a new one) + final SpeedTestResult? existingResult; + + /// Optional AP ID to display uplink speed (for AP speed tests) + final int? apId; + const SpeedTestPopup({ super.key, this.cachedTest, - this.speedTestWithResults, this.onCompleted, this.onResultSubmitted, - }) : assert( - cachedTest != null || speedTestWithResults != null || true, - 'Either cachedTest or speedTestWithResults can be provided, or neither for a standalone test', - ); + this.existingResult, + this.apId, + }); @override - State createState() => _SpeedTestPopupState(); + ConsumerState createState() => _SpeedTestPopupState(); } -class _SpeedTestPopupState extends State +class _SpeedTestPopupState extends ConsumerState with SingleTickerProviderStateMixin { - final SpeedTestService _speedTestService = SpeedTestService(); - - SpeedTestStatus _status = SpeedTestStatus.idle; - double _downloadSpeed = 0.0; - double _uploadSpeed = 0.0; - double _latency = 0.0; - double _progress = 0.0; - String _currentPhase = 'Ready to start'; - String? _localIp; - String? _gatewayIp; - String? _serverHost; - String _serverLabel = 'Gateway'; - String? _errorMessage; - bool _testPassed = false; - - StreamSubscription? _statusSubscription; - StreamSubscription? _resultSubscription; - StreamSubscription? _progressSubscription; - StreamSubscription? _statusMessageSubscription; - late AnimationController _pulseController; late Animation _pulseAnimation; + bool _resultSubmitted = false; + bool _isSubmitting = false; + bool? _submissionSuccess; + String? _submissionError; @override void initState() { super.initState(); _initializePulseAnimation(); - _initializeService(); + // Initialize notifier (idempotent) + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(speedTestRunNotifierProvider.notifier).initialize(); + }); } void _initializePulseAnimation() { @@ -80,237 +66,184 @@ class _SpeedTestPopupState extends State ); } - Future _initializeService() async { - await _speedTestService.initialize(); - - _status = SpeedTestStatus.idle; - - final gatewayService = NetworkGatewayService(); - _localIp = await gatewayService.getWifiIP(); - - _gatewayIp = await gatewayService.getWifiGateway(); - _serverHost = _gatewayIp; - _serverLabel = 'Gateway'; - - if (_localIp == null) { - LoggerService.warning( - 'Could not get device IP - location permission may be required on iOS', - tag: 'SpeedTestPopup'); - } - - if (mounted) { - setState(() {}); - } - - _statusSubscription = _speedTestService.statusStream.listen((status) { - if (!mounted) return; - setState(() { - _status = status; - _updatePhase(); - }); - }); - - _resultSubscription = _speedTestService.resultStream.listen((result) { - if (!mounted) return; - setState(() { - final serviceStatus = _speedTestService.status; - - if (result.hasError) { - _errorMessage = result.errorMessage; - _currentPhase = 'Test failed'; - } else { - // Update speeds (either live or final) - if (result.downloadSpeed > 0) _downloadSpeed = result.downloadSpeed; - if (result.uploadSpeed > 0) _uploadSpeed = result.uploadSpeed; - if (result.latency > 0) _latency = result.latency; - - // Only update connection info if it's a final result - if (result.localIpAddress != null) _localIp = result.localIpAddress; - if (result.serverHost != null) _serverHost = result.serverHost; - - // If the service finished but our local status hasn't updated yet, sync it - if (serviceStatus == SpeedTestStatus.completed && - _status != SpeedTestStatus.completed) { - _status = SpeedTestStatus.completed; - } - - // Check if this is a complete result - if (result.localIpAddress != null || - _status == SpeedTestStatus.completed || - serviceStatus == SpeedTestStatus.completed) { - _validateTestResults(); - _currentPhase = - _testPassed ? 'Test completed - PASSED!' : 'Test completed'; - } - } - }); - }); - - _progressSubscription = _speedTestService.progressStream.listen((progress) { - if (!mounted) return; - setState(() { - _progress = progress; - _updatePhase(); - }); - }); - - _statusMessageSubscription = - _speedTestService.statusMessageStream.listen((message) { - if (!mounted) return; - setState(() { - _currentPhase = message; - - // Extract server info from fallback attempt messages - if (message.contains('Default gateway')) { - _serverLabel = 'Gateway'; - final match = RegExp(r'\(([^)]+)\)').firstMatch(message); - if (match != null) { - _serverHost = match.group(1); - } - } else if (message.contains('test configuration') || - message.contains('Test configuration')) { - _serverLabel = 'Target'; - final match = RegExp(r'\(([^)]+)\)').firstMatch(message); - if (match != null) { - _serverHost = match.group(1); - } - } else if (message.contains('external server') || - message.contains('External server')) { - _serverLabel = 'External'; - final match = RegExp(r'\(([^)]+)\)').firstMatch(message); - if (match != null) { - _serverHost = match.group(1); - } - } else if (message.contains('Testing download speed to') || - message.contains('Testing upload speed to')) { - final match = RegExp(r'to ([\w\.\-]+)').firstMatch(message); - if (match != null) { - _serverHost = match.group(1); - _serverLabel = (_serverHost == _gatewayIp) ? 'Gateway' : 'Target'; - } - } - }); - }); - } - - void _updatePhase() { - if (_status == SpeedTestStatus.running && - _currentPhase == 'Ready to start') { - if (_progress < 50) { - _currentPhase = 'Testing download speed...'; - } else { - _currentPhase = 'Testing upload speed...'; - } - } else if (_status == SpeedTestStatus.completed && - _currentPhase != 'Test completed!') { - _currentPhase = 'Test completed!'; - } else if (_status == SpeedTestStatus.error && - _currentPhase != 'Test failed') { - _currentPhase = 'Test failed'; - } + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); } - Future _startTest() async { - final gatewayService = NetworkGatewayService(); - final gatewayIp = await gatewayService.getWifiGateway(); + /// Get the effective config + SpeedTestConfig? get _effectiveConfig => widget.cachedTest; - setState(() { - _currentPhase = 'Starting test...'; - _serverLabel = 'Gateway'; - _serverHost = gatewayIp ?? 'Detecting...'; - }); + double? _getMinDownload() => _effectiveConfig?.minDownloadMbps; + double? _getMinUpload() => _effectiveConfig?.minUploadMbps; + String? _getConfigTarget() => _effectiveConfig?.target; + String? _getConfigName() => _effectiveConfig?.name; - // Get target from effective config (works with both cachedTest and speedTestWithResults) + Future _startTest() async { + final notifier = ref.read(speedTestRunNotifierProvider.notifier); final configTarget = _getConfigTarget(); - // Run test: tries local gateway first, then falls back to config target - await _speedTestService.runSpeedTestWithFallback(configTarget: configTarget); + await notifier.startTest( + config: _effectiveConfig, + configTarget: configTarget, + ); } - void _cancelTest() async { - await _speedTestService.cancelTest(); + Future _cancelTest() async { + await ref.read(speedTestRunNotifierProvider.notifier).cancelTest(); if (mounted) { Navigator.of(context).pop(); } } - /// Get the effective config from either cachedTest or speedTestWithResults - SpeedTestConfig? get _effectiveConfig { - return widget.cachedTest ?? widget.speedTestWithResults?.config; - } + Future _handleTestCompleted() async { + if (_resultSubmitted) return; - double? _getMinDownload() { - return _effectiveConfig?.minDownloadMbps; - } + final testState = ref.read(speedTestRunNotifierProvider); + final result = testState.completedResult; - double? _getMinUpload() { - return _effectiveConfig?.minUploadMbps; - } - - String? _getConfigTarget() { - return _effectiveConfig?.target; - } + if (result == null) return; - String? _getConfigName() { - return _effectiveConfig?.name; + // Submit result via callback if provided + if (widget.onResultSubmitted != null) { + final submitResult = SpeedTestResult( + downloadMbps: testState.downloadSpeed, + uploadMbps: testState.uploadSpeed, + rtt: testState.latency, + localIpAddress: testState.localIpAddress, + serverHost: testState.serverHost, + speedTestId: _effectiveConfig?.id, + passed: testState.testPassed ?? false, + initiatedAt: result.initiatedAt, + completedAt: DateTime.now(), + testType: 'iperf3', + source: testState.localIpAddress, + destination: testState.serverHost, + port: testState.serverPort, + iperfProtocol: testState.useUdp ? 'udp' : 'tcp', + ); + + LoggerService.info( + 'SpeedTestPopup: Auto-submitting result via callback - passed=${testState.testPassed}, ' + 'download=${testState.downloadSpeed}, upload=${testState.uploadSpeed}', + tag: 'SpeedTestPopup', + ); + + widget.onResultSubmitted?.call(submitResult); + _resultSubmitted = true; + } else if (_effectiveConfig != null) { + // No callback provided, submit internally via provider + await _submitResultInternally(); + } } - void _validateTestResults() { - final config = _effectiveConfig; + Future _submitResultInternally() async { + if (_isSubmitting || _resultSubmitted) return; - if (config == null) { - _testPassed = true; - } else { - final minDownload = _getMinDownload(); - final minUpload = _getMinUpload(); + setState(() { + _isSubmitting = true; + _submissionSuccess = null; + _submissionError = null; + }); - final downloadPassed = minDownload == null || _downloadSpeed >= minDownload; - final uploadPassed = minUpload == null || _uploadSpeed >= minUpload; + try { + final existingId = widget.existingResult?.id; + final isUpdate = existingId != null; + + LoggerService.info( + 'SpeedTestPopup: existingResult=${widget.existingResult != null}, ' + 'existingId=$existingId, isUpdate=$isUpdate, ' + 'configId=${_effectiveConfig?.id}', + tag: 'SpeedTestPopup', + ); + + SpeedTestResult? resultFromServer; + + if (isUpdate) { + // Update existing result with new test data + final testState = ref.read(speedTestRunNotifierProvider); + final completedResult = testState.completedResult; + + final updatedResult = widget.existingResult!.copyWith( + downloadMbps: testState.downloadSpeed, + uploadMbps: testState.uploadSpeed, + rtt: testState.latency, + passed: testState.testPassed ?? false, + initiatedAt: completedResult?.initiatedAt ?? DateTime.now(), + completedAt: DateTime.now(), + testType: 'iperf3', + source: testState.localIpAddress, + destination: testState.serverHost, + port: testState.serverPort, + iperfProtocol: testState.useUdp ? 'udp' : 'tcp', + ); - _testPassed = downloadPassed && uploadPassed; - } + LoggerService.info( + 'SpeedTestPopup: Updating result id=$existingId with ' + 'download=${testState.downloadSpeed}, upload=${testState.uploadSpeed}, ' + 'source=${testState.localIpAddress}, destination=${testState.serverHost}', + tag: 'SpeedTestPopup', + ); - // Auto-submit result when test completes (passed or failed) - if (widget.onResultSubmitted != null) { - _submitResult(); - } - } + final notifier = ref.read( + speedTestResultsNotifierProvider( + speedTestId: updatedResult.speedTestId, + ).notifier, + ); + resultFromServer = await notifier.updateResult(updatedResult); + } else { + // Create new result + LoggerService.info( + 'SpeedTestPopup: Creating new result (no existing result to update)', + tag: 'SpeedTestPopup', + ); + resultFromServer = await ref + .read(speedTestRunNotifierProvider.notifier) + .submitResult(); + } - /// Submit the test result via callback - void _submitResult() { - final result = SpeedTestResult( - downloadMbps: _downloadSpeed, - uploadMbps: _uploadSpeed, - rtt: _latency, - localIpAddress: _localIp, - serverHost: _serverHost, - speedTestId: _effectiveConfig?.id, - passed: _testPassed, - completedAt: DateTime.now(), - testType: 'iperf3', - source: _localIp, - destination: _serverHost, - port: _speedTestService.serverPort, - iperfProtocol: _speedTestService.useUdp ? 'udp' : 'tcp', - ); + final success = resultFromServer != null; - LoggerService.info( - 'SpeedTestPopup: Auto-submitting result - passed=$_testPassed, ' - 'download=$_downloadSpeed, upload=$_uploadSpeed', - tag: 'SpeedTestPopup', - ); + // Update the WebSocket cache so it appears in the list + if (resultFromServer != null) { + ref + .read(webSocketCacheIntegrationProvider) + .updateSpeedTestResultInCache(resultFromServer); + LoggerService.info( + 'SpeedTestPopup: ${isUpdate ? "Updated" : "Added"} result ${resultFromServer.id} in cache', + tag: 'SpeedTestPopup', + ); + } - widget.onResultSubmitted?.call(result); - } + if (mounted) { + setState(() { + _isSubmitting = false; + _submissionSuccess = success; + _resultSubmitted = true; + if (!success) { + _submissionError = 'Failed to ${isUpdate ? "update" : "submit"} result'; + } + }); + } - @override - void dispose() { - _statusSubscription?.cancel(); - _resultSubscription?.cancel(); - _progressSubscription?.cancel(); - _statusMessageSubscription?.cancel(); - _pulseController.dispose(); - super.dispose(); + LoggerService.info( + 'SpeedTestPopup: ${isUpdate ? "Update" : "Submission"} ${success ? "succeeded" : "failed"}', + tag: 'SpeedTestPopup', + ); + } catch (e) { + LoggerService.error( + 'SpeedTestPopup: Error submitting result: $e', + tag: 'SpeedTestPopup', + ); + if (mounted) { + setState(() { + _isSubmitting = false; + _submissionSuccess = false; + _submissionError = e.toString(); + }); + } + } } String _formatSpeed(double speed) { @@ -321,8 +254,8 @@ class _SpeedTestPopupState extends State } } - Color _getStatusColor() { - switch (_status) { + Color _getStatusColor(SpeedTestStatus status) { + switch (status) { case SpeedTestStatus.running: return AppColors.primary; case SpeedTestStatus.completed: @@ -334,8 +267,8 @@ class _SpeedTestPopupState extends State } } - IconData _getStatusIcon() { - switch (_status) { + IconData _getStatusIcon(SpeedTestStatus status) { + switch (status) { case SpeedTestStatus.running: return Icons.speed; case SpeedTestStatus.completed: @@ -393,28 +326,28 @@ class _SpeedTestPopupState extends State color: AppColors.gray500, ), ), - if (minRequired != null) ...[ - const SizedBox(height: 2), - SizedBox( - width: 110, - child: Text( - 'Min: ${_formatSpeed(minRequired)}', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 8, - color: AppColors.gray400, - fontStyle: FontStyle.italic, - fontFamily: 'monospace', - ), + const SizedBox(height: 2), + SizedBox( + width: 110, + child: Text( + minRequired != null + ? 'Min: ${_formatSpeed(minRequired)}' + : 'Min: Not set', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 8, + color: AppColors.gray400, + fontStyle: FontStyle.italic, + fontFamily: 'monospace', ), ), - ], + ), ], ), ); } - Widget _buildLatencyIndicator() { + Widget _buildLatencyIndicator(double latency) { return Container( padding: const EdgeInsets.all(12), constraints: const BoxConstraints( @@ -434,7 +367,7 @@ class _SpeedTestPopupState extends State SizedBox( width: 110, child: Text( - '${_latency.toStringAsFixed(0)} ms', + '${latency.toStringAsFixed(0)} ms', textAlign: TextAlign.center, style: const TextStyle( fontSize: 14, @@ -459,8 +392,19 @@ class _SpeedTestPopupState extends State @override Widget build(BuildContext context) { + final testState = ref.watch(speedTestRunNotifierProvider); + final status = testState.executionStatus; + final testPassed = testState.testPassed; + + // Auto-submit when test completes + if (status == SpeedTestStatus.completed && !_resultSubmitted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleTestCompleted(); + }); + } + return PopScope( - canPop: _status != SpeedTestStatus.running, + canPop: status != SpeedTestStatus.running, child: Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Container( @@ -477,18 +421,18 @@ class _SpeedTestPopupState extends State animation: _pulseAnimation, builder: (context, child) { return Transform.scale( - scale: _status == SpeedTestStatus.running + scale: status == SpeedTestStatus.running ? _pulseAnimation.value : 1.0, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: _getStatusColor().withOpacity(0.2), + color: _getStatusColor(status).withOpacity(0.2), shape: BoxShape.circle, ), child: Icon( - _getStatusIcon(), - color: _getStatusColor(), + _getStatusIcon(status), + color: _getStatusColor(status), size: 32, ), ), @@ -508,7 +452,7 @@ class _SpeedTestPopupState extends State ), ), Text( - _currentPhase, + testState.statusMessage ?? 'Ready to start', style: TextStyle( fontSize: 14, color: AppColors.gray500, @@ -517,7 +461,7 @@ class _SpeedTestPopupState extends State ], ), ), - if (_status != SpeedTestStatus.running) + if (status != SpeedTestStatus.running) IconButton( icon: const Icon(Icons.close), onPressed: () { @@ -544,11 +488,11 @@ class _SpeedTestPopupState extends State Row( children: [ Icon( - _localIp != null + testState.localIpAddress != null ? Icons.computer : Icons.location_off, size: 16, - color: _localIp != null + color: testState.localIpAddress != null ? AppColors.gray500 : Colors.orange, ), @@ -560,9 +504,9 @@ class _SpeedTestPopupState extends State color: AppColors.gray500, ), ), - if (_localIp != null) + if (testState.localIpAddress != null) Text( - _localIp!, + testState.localIpAddress!, style: TextStyle( fontSize: 12, color: AppColors.gray300, @@ -595,18 +539,18 @@ class _SpeedTestPopupState extends State ), ), Text( - _serverLabel, + 'Target', style: TextStyle( fontSize: 12, color: AppColors.primary, fontWeight: FontWeight.bold, ), ), - if (_serverHost != null) ...[ + if (testState.serverHost.isNotEmpty) ...[ const SizedBox(width: 4), Flexible( child: Text( - '($_serverHost)', + '(${testState.serverHost})', style: TextStyle( fontSize: 11, color: AppColors.gray500, @@ -618,6 +562,79 @@ class _SpeedTestPopupState extends State ], ], ), + // Uplink speed row (for AP speed tests) + if (widget.apId != null) ...[ + const SizedBox(height: 6), + Consumer( + builder: (context, ref, _) { + final uplinkAsync = + ref.watch(apUplinkInfoProvider(widget.apId!)); + return Row( + children: [ + const Icon( + Icons.cable, + size: 16, + color: AppColors.gray500, + ), + const SizedBox(width: 8), + const Text( + 'Uplink: ', + style: TextStyle( + fontSize: 12, + color: AppColors.gray500, + ), + ), + uplinkAsync.when( + data: (uplink) { + if (uplink == null) { + return const Text( + 'Not available', + style: TextStyle( + fontSize: 12, + color: AppColors.warning, + ), + ); + } + final speedBps = uplink.speedInBps; + final speedGbps = speedBps != null + ? speedBps / 1000000000 + : null; + final isSlowUplink = speedBps != null && + speedBps < 2500000000; + return Text( + speedGbps != null + ? '${speedGbps.toStringAsFixed(1)} Gbps' + : 'Unknown', + style: TextStyle( + fontSize: 12, + color: isSlowUplink + ? AppColors.error + : AppColors.gray300, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ); + }, + loading: () => const Text( + 'Loading...', + style: TextStyle( + fontSize: 12, + color: AppColors.gray500, + ), + ), + error: (_, __) => const Text( + 'Error', + style: TextStyle( + fontSize: 12, + color: AppColors.error, + ), + ), + ), + ], + ); + }, + ), + ], ], ), ), @@ -766,7 +783,7 @@ class _SpeedTestPopupState extends State Expanded( child: _buildSpeedIndicator( 'Download', - _downloadSpeed, + testState.downloadSpeed, Icons.download, AppColors.success, minRequired: _getMinDownload(), @@ -776,7 +793,7 @@ class _SpeedTestPopupState extends State Expanded( child: _buildSpeedIndicator( 'Upload', - _uploadSpeed, + testState.uploadSpeed, Icons.upload, AppColors.info, minRequired: _getMinUpload(), @@ -791,13 +808,13 @@ class _SpeedTestPopupState extends State // Latency indicator SizedBox( height: 110, - child: _buildLatencyIndicator(), + child: _buildLatencyIndicator(testState.latency), ), const SizedBox(height: 20), // Progress indicator - if (_status == SpeedTestStatus.running) ...[ + if (status == SpeedTestStatus.running) ...[ Center( child: Column( children: [ @@ -805,18 +822,18 @@ class _SpeedTestPopupState extends State width: 48, height: 48, child: CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation(_getStatusColor()), + valueColor: AlwaysStoppedAnimation( + _getStatusColor(status)), strokeWidth: 4, ), ), const SizedBox(height: 12), Text( - _currentPhase, + testState.statusMessage ?? 'Testing...', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: _getStatusColor(), + color: _getStatusColor(status), ), ), ], @@ -825,14 +842,15 @@ class _SpeedTestPopupState extends State ], // Error message - if (_errorMessage != null) ...[ + if (testState.errorMessage != null) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.error.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.error.withOpacity(0.3)), + border: + Border.all(color: AppColors.error.withOpacity(0.3)), ), child: Row( children: [ @@ -841,7 +859,7 @@ class _SpeedTestPopupState extends State const SizedBox(width: 8), Expanded( child: Text( - _errorMessage!, + testState.errorMessage!, style: const TextStyle( color: AppColors.error, fontSize: 12, @@ -854,15 +872,16 @@ class _SpeedTestPopupState extends State ], // Threshold failure alert - if (_status == SpeedTestStatus.completed && !_testPassed) ...[ + if (status == SpeedTestStatus.completed && + testPassed == false) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.warning.withOpacity(0.1), borderRadius: BorderRadius.circular(8), - border: - Border.all(color: AppColors.warning.withOpacity(0.3)), + border: Border.all( + color: AppColors.warning.withOpacity(0.3)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -898,10 +917,115 @@ class _SpeedTestPopupState extends State ), ], + // Submission status feedback + if (status == SpeedTestStatus.completed && _effectiveConfig != null) ...[ + const SizedBox(height: 16), + if (_isSubmitting) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + const SizedBox(width: 12), + Text( + 'Submitting result...', + style: TextStyle( + color: AppColors.primary, + fontSize: 13, + ), + ), + ], + ), + ) + else if (_submissionSuccess == true) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.success.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.success.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.check_circle, + color: AppColors.success, + size: 20, + ), + const SizedBox(width: 12), + Text( + 'Result submitted successfully', + style: TextStyle( + color: AppColors.success, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + else if (_submissionSuccess == false) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: AppColors.error, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _submissionError ?? 'Failed to submit result', + style: TextStyle( + color: AppColors.error, + fontSize: 13, + ), + ), + ), + TextButton( + onPressed: _submitResultInternally, + child: Text( + 'Retry', + style: TextStyle( + color: AppColors.error, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ], + // Action buttons const SizedBox(height: 16), - if (_status == SpeedTestStatus.idle) ...[ + if (status == SpeedTestStatus.idle) ...[ Row( children: [ Expanded( @@ -935,7 +1059,7 @@ class _SpeedTestPopupState extends State ), ], - if (_status == SpeedTestStatus.running) ...[ + if (status == SpeedTestStatus.running) ...[ SizedBox( width: double.infinity, child: OutlinedButton.icon( @@ -950,7 +1074,7 @@ class _SpeedTestPopupState extends State ), ], - if (_status == SpeedTestStatus.completed) ...[ + if (status == SpeedTestStatus.completed) ...[ Row( children: [ Expanded( @@ -974,13 +1098,14 @@ class _SpeedTestPopupState extends State child: ElevatedButton.icon( onPressed: () { setState(() { - _downloadSpeed = 0.0; - _uploadSpeed = 0.0; - _latency = 0.0; - _progress = 0.0; - _errorMessage = null; - _testPassed = false; + _resultSubmitted = false; + _isSubmitting = false; + _submissionSuccess = null; + _submissionError = null; }); + ref + .read(speedTestRunNotifierProvider.notifier) + .reset(); _startTest(); }, icon: const Icon(Icons.refresh, size: 16), @@ -997,7 +1122,7 @@ class _SpeedTestPopupState extends State ), ], - if (_status == SpeedTestStatus.error) ...[ + if (status == SpeedTestStatus.error) ...[ Row( children: [ Expanded( @@ -1020,12 +1145,14 @@ class _SpeedTestPopupState extends State child: ElevatedButton.icon( onPressed: () { setState(() { - _errorMessage = null; - _downloadSpeed = 0.0; - _uploadSpeed = 0.0; - _latency = 0.0; - _progress = 0.0; + _resultSubmitted = false; + _isSubmitting = false; + _submissionSuccess = null; + _submissionError = null; }); + ref + .read(speedTestRunNotifierProvider.notifier) + .reset(); _startTest(); }, icon: const Icon(Icons.refresh, size: 16),