From 6ff11b8c1ee8001a511c6aca76b42c8e5d8fe55f Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Wed, 22 Apr 2026 20:21:35 +0200 Subject: [PATCH] fix(router): rename duplicate notifications route name --- .../lib/core/notifications_init.dart | 2 +- .../mobile_app/lib/navigation/app_router.dart | 11 +- .../control_panel/lib/control_panel.dart | 3 +- .../presentation/state/alerts_view_model.dart | 117 ------- .../presentation/state/alerts_view_state.dart | 20 -- .../state/alerts_view_state.freezed.dart | 295 ------------------ .../presentation/control_panel_screen.dart | 11 - .../notifications_builder.dart} | 8 +- .../presentation/notifications_screen.dart} | 83 ++--- .../notifications_feed_provider.dart | 116 +++++++ .../notifications_feed_provider.g.dart | 61 ++++ .../notifications_filter_provider.dart | 14 + .../notifications_filter_provider.g.dart | 64 ++++ .../widgets/notification_card.dart} | 33 +- .../legacy/modules/control_panel/pubspec.yaml | 2 + .../notifications_feed_test.dart | 224 +++++++++++++ .../legacy/modules/settings/lib/settings.dart | 1 - .../legacy_notifications_builder.dart | 15 - .../legacy_notifications_screen.dart | 17 - .../presentation/settings_screen.dart | 4 +- .../wifi_current_network_provider.dart | 1 - .../providers/wifi_scan_provider.dart | 1 - .../wifi_settings/wifi_controller_test.dart | 1 - .../lib/legacy_device_state.dart | 1 + .../web_socket_service_provider.dart | 0 .../web_socket_service_provider.g.dart | 0 .../packages/legacy_device_state/pubspec.yaml | 2 + packages/navigation/lib/app_routes.dart | 3 +- 28 files changed, 559 insertions(+), 551 deletions(-) delete mode 100644 modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_model.dart delete mode 100644 modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_state.dart delete mode 100644 modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_state.freezed.dart rename modules/legacy/modules/control_panel/lib/src/features/{alerts/alerts_builder.dart => notifications/notifications_builder.dart} (66%) rename modules/legacy/modules/control_panel/lib/src/features/{alerts/presentation/alerts_screen.dart => notifications/presentation/notifications_screen.dart} (68%) create mode 100644 modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_feed_provider.dart create mode 100644 modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_feed_provider.g.dart create mode 100644 modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_filter_provider.dart create mode 100644 modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_filter_provider.g.dart rename modules/legacy/modules/control_panel/lib/src/features/{alerts/presentation/widgets/alert_card.dart => notifications/presentation/widgets/notification_card.dart} (84%) create mode 100644 modules/legacy/modules/control_panel/test/features/notifications/notifications_feed_test.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/legacy_notifications/legacy_notifications_builder.dart delete mode 100644 modules/legacy/modules/settings/lib/src/features/legacy_notifications/presentation/legacy_notifications_screen.dart rename modules/legacy/{modules/settings/lib/src/features/wifi_settings/presentation => packages/legacy_device_state/lib/src}/providers/web_socket_service_provider.dart (100%) rename modules/legacy/{modules/settings/lib/src/features/wifi_settings/presentation => packages/legacy_device_state/lib/src}/providers/web_socket_service_provider.g.dart (100%) diff --git a/apps/mobile_app/lib/core/notifications_init.dart b/apps/mobile_app/lib/core/notifications_init.dart index 3fbe2b1f..c9db7515 100644 --- a/apps/mobile_app/lib/core/notifications_init.dart +++ b/apps/mobile_app/lib/core/notifications_init.dart @@ -169,7 +169,7 @@ void _handleNotificationNavigation(Map data) { switch (command) { case 'ALERT': - appRouter.go(AppRoutes.deviceAlertsNotifications); + appRouter.go(AppRoutes.deviceNotifications); default: debugPrint('[Notification] unhandled command: $command'); } diff --git a/apps/mobile_app/lib/navigation/app_router.dart b/apps/mobile_app/lib/navigation/app_router.dart index 6af328b0..de6df5d4 100644 --- a/apps/mobile_app/lib/navigation/app_router.dart +++ b/apps/mobile_app/lib/navigation/app_router.dart @@ -97,9 +97,9 @@ void configureAppRouter() { pageBuilder: CustomerServiceBuilder().buildPage, ), GoRoute( - path: 'device_alerts_notifications', - name: 'device_alerts_notifications', - pageBuilder: const DeviceAlertsBuilder().buildPage, + path: 'notifications', + name: 'legacy_notifications', + pageBuilder: const DeviceNotificationsBuilder().buildPage, ), GoRoute( path: 'account_settings', @@ -305,11 +305,6 @@ void configureAppRouter() { name: 'language', pageBuilder: LanguageBuilder().buildPage, ), - GoRoute( - path: 'legacy_notifications', - name: 'legacy_notifications', - pageBuilder: LegacyNotificationsBuilder().buildPage, - ), GoRoute( path: 'remote_on_off', name: 'remote_on_off', diff --git a/modules/legacy/modules/control_panel/lib/control_panel.dart b/modules/legacy/modules/control_panel/lib/control_panel.dart index a278c22b..c72db8ab 100644 --- a/modules/legacy/modules/control_panel/lib/control_panel.dart +++ b/modules/legacy/modules/control_panel/lib/control_panel.dart @@ -1,3 +1,4 @@ -export 'src/features/alerts/alerts_builder.dart' show DeviceAlertsBuilder; +export 'src/features/notifications/notifications_builder.dart' + show DeviceNotificationsBuilder; export 'src/features/control_panel/control_panel_builder.dart'; export 'src/shared/widgets/device_map.dart'; diff --git a/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_model.dart b/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_model.dart deleted file mode 100644 index e4876e62..00000000 --- a/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_model.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:get_it/get_it.dart'; -import 'package:sf_infrastructure/sf_infrastructure.dart'; - -import '../../../../core/domain/entities/alert_entity.dart'; -import '../../../../core/domain/repositories/alerts_repository.dart'; -import '../../../../core/providers/alerts_repository_provider.dart'; -import 'alerts_view_state.dart'; - -final alertsViewModelProvider = - NotifierProvider.autoDispose( - AlertsViewModel.new, - ); - -class AlertsViewModel extends Notifier { - late final AlertsRepository _repository; - late final WebSocketService _webSocket; - StreamSubscription? _webSocketSubscription; - - static const int _pageSize = 20; - - @override - AlertsViewState build() { - _repository = ref.read(alertsRepositoryProvider); - _webSocket = GetIt.I(); - - _webSocketSubscription = _webSocket.events.listen(_onWebSocketEvent); - ref.onDispose(() => _webSocketSubscription?.cancel()); - - Future.microtask(_load); - return const AlertsViewState(); - } - - void _onWebSocketEvent(WebSocketEvent event) { - if (event is! AlertEvent) return; - - final newAlert = AlertEntity( - id: '', - deviceIdentificator: event.deviceIdentificator, - deviceName: '', - type: event.type, - createdAt: DateTime.now().millisecondsSinceEpoch, - ); - - if (state.selectedType != null && state.selectedType != event.type) return; - - state = state.copyWith(alerts: [newAlert, ...state.alerts]); - } - - Future _load() async { - try { - final (alerts, totalPages) = await _repository.getAlerts( - page: 1, - pageSize: _pageSize, - type: state.selectedType, - ); - if (!ref.mounted) return; - - state = state.copyWith( - alerts: alerts, - isLoading: false, - currentPage: 1, - hasMore: totalPages > 1, - error: null, - ); - } catch (_) { - if (!ref.mounted) return; - state = state.copyWith( - isLoading: false, - error: AlertsError.loadFailed, - ); - } - } - - Future loadMore() async { - if (state.isLoadingMore || !state.hasMore) return; - - state = state.copyWith(isLoadingMore: true); - - try { - final nextPage = state.currentPage + 1; - final (alerts, totalPages) = await _repository.getAlerts( - page: nextPage, - pageSize: _pageSize, - type: state.selectedType, - ); - if (!ref.mounted) return; - - state = state.copyWith( - alerts: [...state.alerts, ...alerts], - isLoadingMore: false, - currentPage: nextPage, - hasMore: nextPage < totalPages, - ); - } catch (_) { - if (!ref.mounted) return; - state = state.copyWith(isLoadingMore: false); - } - } - - void filterByType(String? type) { - state = state.copyWith( - selectedType: type, - isLoading: true, - alerts: [], - currentPage: 1, - hasMore: false, - ); - _load(); - } - - void clearError() { - if (state.error != null) state = state.copyWith(error: null); - } -} diff --git a/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_state.dart b/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_state.dart deleted file mode 100644 index 25e0fda5..00000000 --- a/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_state.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; - -import '../../../../core/domain/entities/alert_entity.dart'; - -part 'alerts_view_state.freezed.dart'; - -enum AlertsError { loadFailed } - -@freezed -abstract class AlertsViewState with _$AlertsViewState { - const factory AlertsViewState({ - @Default([]) List alerts, - @Default(true) bool isLoading, - @Default(false) bool isLoadingMore, - @Default(1) int currentPage, - @Default(false) bool hasMore, - String? selectedType, - AlertsError? error, - }) = _AlertsViewState; -} diff --git a/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_state.freezed.dart b/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_state.freezed.dart deleted file mode 100644 index 9d19dc40..00000000 --- a/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/state/alerts_view_state.freezed.dart +++ /dev/null @@ -1,295 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// coverage:ignore-file -// 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 'alerts_view_state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -T _$identity(T value) => value; -/// @nodoc -mixin _$AlertsViewState { - - List get alerts; bool get isLoading; bool get isLoadingMore; int get currentPage; bool get hasMore; String? get selectedType; AlertsError? get error; -/// Create a copy of AlertsViewState -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$AlertsViewStateCopyWith get copyWith => _$AlertsViewStateCopyWithImpl(this as AlertsViewState, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is AlertsViewState&&const DeepCollectionEquality().equals(other.alerts, alerts)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.currentPage, currentPage) || other.currentPage == currentPage)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.selectedType, selectedType) || other.selectedType == selectedType)&&(identical(other.error, error) || other.error == error)); -} - - -@override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(alerts),isLoading,isLoadingMore,currentPage,hasMore,selectedType,error); - -@override -String toString() { - return 'AlertsViewState(alerts: $alerts, isLoading: $isLoading, isLoadingMore: $isLoadingMore, currentPage: $currentPage, hasMore: $hasMore, selectedType: $selectedType, error: $error)'; -} - - -} - -/// @nodoc -abstract mixin class $AlertsViewStateCopyWith<$Res> { - factory $AlertsViewStateCopyWith(AlertsViewState value, $Res Function(AlertsViewState) _then) = _$AlertsViewStateCopyWithImpl; -@useResult -$Res call({ - List alerts, bool isLoading, bool isLoadingMore, int currentPage, bool hasMore, String? selectedType, AlertsError? error -}); - - - - -} -/// @nodoc -class _$AlertsViewStateCopyWithImpl<$Res> - implements $AlertsViewStateCopyWith<$Res> { - _$AlertsViewStateCopyWithImpl(this._self, this._then); - - final AlertsViewState _self; - final $Res Function(AlertsViewState) _then; - -/// Create a copy of AlertsViewState -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? alerts = null,Object? isLoading = null,Object? isLoadingMore = null,Object? currentPage = null,Object? hasMore = null,Object? selectedType = freezed,Object? error = freezed,}) { - return _then(_self.copyWith( -alerts: null == alerts ? _self.alerts : alerts // ignore: cast_nullable_to_non_nullable -as List,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable -as bool,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable -as bool,currentPage: null == currentPage ? _self.currentPage : currentPage // ignore: cast_nullable_to_non_nullable -as int,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable -as bool,selectedType: freezed == selectedType ? _self.selectedType : selectedType // ignore: cast_nullable_to_non_nullable -as String?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable -as AlertsError?, - )); -} - -} - - -/// Adds pattern-matching-related methods to [AlertsViewState]. -extension AlertsViewStatePatterns on AlertsViewState { -/// A variant of `map` that fallback to returning `orElse`. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case _: -/// return orElse(); -/// } -/// ``` - -@optionalTypeArgs TResult maybeMap(TResult Function( _AlertsViewState value)? $default,{required TResult orElse(),}){ -final _that = this; -switch (_that) { -case _AlertsViewState() when $default != null: -return $default(_that);case _: - return orElse(); - -} -} -/// A `switch`-like method, using callbacks. -/// -/// Callbacks receives the raw object, upcasted. -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case final Subclass2 value: -/// return ...; -/// } -/// ``` - -@optionalTypeArgs TResult map(TResult Function( _AlertsViewState value) $default,){ -final _that = this; -switch (_that) { -case _AlertsViewState(): -return $default(_that);case _: - throw StateError('Unexpected subclass'); - -} -} -/// A variant of `map` that fallback to returning `null`. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case final Subclass value: -/// return ...; -/// case _: -/// return null; -/// } -/// ``` - -@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AlertsViewState value)? $default,){ -final _that = this; -switch (_that) { -case _AlertsViewState() when $default != null: -return $default(_that);case _: - return null; - -} -} -/// A variant of `when` that fallback to an `orElse` callback. -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case _: -/// return orElse(); -/// } -/// ``` - -@optionalTypeArgs TResult maybeWhen(TResult Function( List alerts, bool isLoading, bool isLoadingMore, int currentPage, bool hasMore, String? selectedType, AlertsError? error)? $default,{required TResult orElse(),}) {final _that = this; -switch (_that) { -case _AlertsViewState() when $default != null: -return $default(_that.alerts,_that.isLoading,_that.isLoadingMore,_that.currentPage,_that.hasMore,_that.selectedType,_that.error);case _: - return orElse(); - -} -} -/// A `switch`-like method, using callbacks. -/// -/// As opposed to `map`, this offers destructuring. -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case Subclass2(:final field2): -/// return ...; -/// } -/// ``` - -@optionalTypeArgs TResult when(TResult Function( List alerts, bool isLoading, bool isLoadingMore, int currentPage, bool hasMore, String? selectedType, AlertsError? error) $default,) {final _that = this; -switch (_that) { -case _AlertsViewState(): -return $default(_that.alerts,_that.isLoading,_that.isLoadingMore,_that.currentPage,_that.hasMore,_that.selectedType,_that.error);case _: - throw StateError('Unexpected subclass'); - -} -} -/// A variant of `when` that fallback to returning `null` -/// -/// It is equivalent to doing: -/// ```dart -/// switch (sealedClass) { -/// case Subclass(:final field): -/// return ...; -/// case _: -/// return null; -/// } -/// ``` - -@optionalTypeArgs TResult? whenOrNull(TResult? Function( List alerts, bool isLoading, bool isLoadingMore, int currentPage, bool hasMore, String? selectedType, AlertsError? error)? $default,) {final _that = this; -switch (_that) { -case _AlertsViewState() when $default != null: -return $default(_that.alerts,_that.isLoading,_that.isLoadingMore,_that.currentPage,_that.hasMore,_that.selectedType,_that.error);case _: - return null; - -} -} - -} - -/// @nodoc - - -class _AlertsViewState implements AlertsViewState { - const _AlertsViewState({final List alerts = const [], this.isLoading = true, this.isLoadingMore = false, this.currentPage = 1, this.hasMore = false, this.selectedType, this.error}): _alerts = alerts; - - - final List _alerts; -@override@JsonKey() List get alerts { - if (_alerts is EqualUnmodifiableListView) return _alerts; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_alerts); -} - -@override@JsonKey() final bool isLoading; -@override@JsonKey() final bool isLoadingMore; -@override@JsonKey() final int currentPage; -@override@JsonKey() final bool hasMore; -@override final String? selectedType; -@override final AlertsError? error; - -/// Create a copy of AlertsViewState -/// with the given fields replaced by the non-null parameter values. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$AlertsViewStateCopyWith<_AlertsViewState> get copyWith => __$AlertsViewStateCopyWithImpl<_AlertsViewState>(this, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _AlertsViewState&&const DeepCollectionEquality().equals(other._alerts, _alerts)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.currentPage, currentPage) || other.currentPage == currentPage)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.selectedType, selectedType) || other.selectedType == selectedType)&&(identical(other.error, error) || other.error == error)); -} - - -@override -int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_alerts),isLoading,isLoadingMore,currentPage,hasMore,selectedType,error); - -@override -String toString() { - return 'AlertsViewState(alerts: $alerts, isLoading: $isLoading, isLoadingMore: $isLoadingMore, currentPage: $currentPage, hasMore: $hasMore, selectedType: $selectedType, error: $error)'; -} - - -} - -/// @nodoc -abstract mixin class _$AlertsViewStateCopyWith<$Res> implements $AlertsViewStateCopyWith<$Res> { - factory _$AlertsViewStateCopyWith(_AlertsViewState value, $Res Function(_AlertsViewState) _then) = __$AlertsViewStateCopyWithImpl; -@override @useResult -$Res call({ - List alerts, bool isLoading, bool isLoadingMore, int currentPage, bool hasMore, String? selectedType, AlertsError? error -}); - - - - -} -/// @nodoc -class __$AlertsViewStateCopyWithImpl<$Res> - implements _$AlertsViewStateCopyWith<$Res> { - __$AlertsViewStateCopyWithImpl(this._self, this._then); - - final _AlertsViewState _self; - final $Res Function(_AlertsViewState) _then; - -/// Create a copy of AlertsViewState -/// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? alerts = null,Object? isLoading = null,Object? isLoadingMore = null,Object? currentPage = null,Object? hasMore = null,Object? selectedType = freezed,Object? error = freezed,}) { - return _then(_AlertsViewState( -alerts: null == alerts ? _self._alerts : alerts // ignore: cast_nullable_to_non_nullable -as List,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable -as bool,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable -as bool,currentPage: null == currentPage ? _self.currentPage : currentPage // ignore: cast_nullable_to_non_nullable -as int,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable -as bool,selectedType: freezed == selectedType ? _self.selectedType : selectedType // ignore: cast_nullable_to_non_nullable -as String?,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable -as AlertsError?, - )); -} - - -} - -// dart format on diff --git a/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/control_panel_screen.dart b/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/control_panel_screen.dart index fe5e00c4..9dfcc873 100644 --- a/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/control_panel_screen.dart +++ b/modules/legacy/modules/control_panel/lib/src/features/control_panel/presentation/control_panel_screen.dart @@ -132,17 +132,6 @@ class _Header extends ConsumerWidget { 'assets/shared/images/logo_sf.svg', height: SizeUtils.getByScreen(small: 36, big: 36), ), - Positioned( - right: 0, - top: SizeUtils.getByScreen(small: 10, big: 10), - child: IconButton( - onPressed: () => navigationContract.pushTo(AppRoutes.deviceAlertsNotifications), - icon: Icon( - Icons.notifications_outlined, - size: SizeUtils.getByScreen(small: 26, big: 24), - ), - ), - ), ], ); } diff --git a/modules/legacy/modules/control_panel/lib/src/features/alerts/alerts_builder.dart b/modules/legacy/modules/control_panel/lib/src/features/notifications/notifications_builder.dart similarity index 66% rename from modules/legacy/modules/control_panel/lib/src/features/alerts/alerts_builder.dart rename to modules/legacy/modules/control_panel/lib/src/features/notifications/notifications_builder.dart index 4e18ff5b..2586f983 100644 --- a/modules/legacy/modules/control_panel/lib/src/features/alerts/alerts_builder.dart +++ b/modules/legacy/modules/control_panel/lib/src/features/notifications/notifications_builder.dart @@ -3,17 +3,17 @@ import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:navigation/navigation.dart'; -import 'presentation/alerts_screen.dart'; +import 'presentation/notifications_screen.dart'; -class DeviceAlertsBuilder { - const DeviceAlertsBuilder(); +class DeviceNotificationsBuilder { + const DeviceNotificationsBuilder(); Page buildPage(BuildContext context, GoRouterState state) { final NavigationContract navigationContract = GetIt.I(); return MaterialPage( key: state.pageKey, - child: AlertsScreen(navigationContract: navigationContract), + child: NotificationsScreen(navigationContract: navigationContract), ); } } diff --git a/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/alerts_screen.dart b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/notifications_screen.dart similarity index 68% rename from modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/alerts_screen.dart rename to modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/notifications_screen.dart index 8e712ce2..30f21b47 100644 --- a/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/alerts_screen.dart +++ b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/notifications_screen.dart @@ -1,35 +1,30 @@ -import 'package:design_system/design_system.dart'; -import 'package:legacy_theme/legacy_theme.dart'; +import 'package:control_panel/src/core/domain/entities/alert_entity.dart'; +import 'package:control_panel/src/features/notifications/presentation/providers/notifications_feed_provider.dart'; +import 'package:control_panel/src/features/notifications/presentation/providers/notifications_filter_provider.dart'; +import 'package:control_panel/src/features/notifications/presentation/widgets/notification_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:legacy_theme/legacy_theme.dart'; import 'package:navigation/navigation.dart'; import 'package:sf_localizations/sf_localizations.dart'; +import 'package:sf_shared/sf_shared.dart'; import 'package:utils/utils.dart'; -import 'state/alerts_view_model.dart'; -import 'widgets/alert_card.dart'; - -class AlertsScreen extends ConsumerWidget { +class NotificationsScreen extends ConsumerWidget { final NavigationContract navigationContract; - const AlertsScreen({super.key, required this.navigationContract}); + const NotificationsScreen({super.key, required this.navigationContract}); @override Widget build(BuildContext context, WidgetRef ref) { final primaryColor = context.sfColors.legacyPrimary; - final vm = ref.read(alertsViewModelProvider.notifier); - final (isLoading, selectedType) = ref.watch( - alertsViewModelProvider.select((s) => (s.isLoading, s.selectedType)), - ); + final selectedType = ref.watch(notificationsFilterProvider); + final feedAsync = ref.watch(notificationsFeedProvider); - ref.listen(alertsViewModelProvider.select((s) => s.error), (_, error) { - if (error == null) return; - showTopSnackbar( - context, - message: context.translate(I18n.alertsLoadError), - type: MessageType.error, - ); - vm.clearError(); + ref.listen(notificationsFeedProvider, (prev, next) async { + if (next.hasError && prev?.hasError != true) { + await showErrorDialog(context, I18n.alertsLoadError); + } }); return Scaffold( @@ -49,7 +44,7 @@ class AlertsScreen extends ConsumerWidget { ), ), title: Text( - context.translate(I18n.alertsTitle).toUpperCase(), + context.translate(I18n.notificationsLegacyTitle).toUpperCase(), style: TextStyle( fontSize: SizeUtils.getByScreen(small: 20, big: 19), fontWeight: FontWeight.w500, @@ -62,12 +57,19 @@ class AlertsScreen extends ConsumerWidget { children: [ _FilterChips( selectedType: selectedType, - onSelected: vm.filterByType, + onSelected: ref.read(notificationsFilterProvider.notifier).select, ), Expanded( - child: isLoading - ? const Center(child: CircularProgressIndicator()) - : const _AlertsList(), + child: feedAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => Center( + child: Text(context.translate(I18n.alertsLoadError)), + ), + data: (feed) => _NotificationsList( + notifications: feed.notifications, + isLoadingMore: feed.isLoadingMore, + ), + ), ), ], ), @@ -75,14 +77,14 @@ class AlertsScreen extends ConsumerWidget { } } -class _FilterChips extends ConsumerWidget { +class _FilterChips extends StatelessWidget { final String? selectedType; final ValueChanged onSelected; const _FilterChips({required this.selectedType, required this.onSelected}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final primaryColor = context.sfColors.legacyPrimary; final filters = [ @@ -131,14 +133,21 @@ class _FilterChips extends ConsumerWidget { } } -class _AlertsList extends ConsumerStatefulWidget { - const _AlertsList(); +class _NotificationsList extends ConsumerStatefulWidget { + final List notifications; + final bool isLoadingMore; + + const _NotificationsList({ + required this.notifications, + required this.isLoadingMore, + }); @override - ConsumerState<_AlertsList> createState() => _AlertsListState(); + ConsumerState<_NotificationsList> createState() => + _NotificationsListState(); } -class _AlertsListState extends ConsumerState<_AlertsList> { +class _NotificationsListState extends ConsumerState<_NotificationsList> { final _scrollController = ScrollController(); @override @@ -156,17 +165,15 @@ class _AlertsListState extends ConsumerState<_AlertsList> { void _onScroll() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { - ref.read(alertsViewModelProvider.notifier).loadMore(); + ref.read(notificationsFeedProvider.notifier).loadMore(); } } @override Widget build(BuildContext context) { - final (alerts, isLoadingMore) = ref.watch( - alertsViewModelProvider.select((s) => (s.alerts, s.isLoadingMore)), - ); + final notifications = widget.notifications; - if (alerts.isEmpty) { + if (notifications.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -195,15 +202,15 @@ class _AlertsListState extends ConsumerState<_AlertsList> { horizontal: SizeUtils.getByScreen(small: 16, big: 14), vertical: SizeUtils.getByScreen(small: 8, big: 6), ), - itemCount: alerts.length + (isLoadingMore ? 1 : 0), + itemCount: notifications.length + (widget.isLoadingMore ? 1 : 0), itemBuilder: (context, index) { - if (index == alerts.length) { + if (index == notifications.length) { return const Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ); } - return AlertCard(alert: alerts[index]); + return NotificationCard(notification: notifications[index]); }, ); } diff --git a/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_feed_provider.dart b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_feed_provider.dart new file mode 100644 index 00000000..c922cfbc --- /dev/null +++ b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_feed_provider.dart @@ -0,0 +1,116 @@ +import 'dart:async'; + +import 'package:control_panel/src/core/domain/entities/alert_entity.dart'; +import 'package:control_panel/src/core/providers/alerts_repository_provider.dart'; +import 'package:control_panel/src/features/notifications/presentation/providers/notifications_filter_provider.dart'; +import 'package:legacy_device_state/legacy_device_state.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sf_infrastructure/sf_infrastructure.dart'; + +part 'notifications_feed_provider.g.dart'; + +const int _pageSize = 20; + +class NotificationsFeedState { + const NotificationsFeedState({ + this.notifications = const [], + this.currentPage = 1, + this.hasMore = false, + this.isLoadingMore = false, + }); + + final List notifications; + final int currentPage; + final bool hasMore; + final bool isLoadingMore; + + NotificationsFeedState copyWith({ + List? notifications, + int? currentPage, + bool? hasMore, + bool? isLoadingMore, + }) { + return NotificationsFeedState( + notifications: notifications ?? this.notifications, + currentPage: currentPage ?? this.currentPage, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + ); + } +} + +@riverpod +class NotificationsFeed extends _$NotificationsFeed { + StreamSubscription? _subscription; + + @override + Future build() async { + final filter = ref.watch(notificationsFilterProvider); + + _subscription?.cancel(); + _subscription = + ref.read(webSocketServiceProvider).events.listen(_onEvent); + ref.onDispose(() => _subscription?.cancel()); + + final (notifications, totalPages) = await ref + .read(alertsRepositoryProvider) + .getAlerts(page: 1, pageSize: _pageSize, type: filter); + + return NotificationsFeedState( + notifications: notifications, + currentPage: 1, + hasMore: totalPages > 1, + ); + } + + void _onEvent(WebSocketEvent event) { + if (event is! AlertEvent) return; + + final current = state.value; + if (current == null) return; + + final filter = ref.read(notificationsFilterProvider); + if (filter != null && filter != event.type) return; + + final newNotification = AlertEntity( + id: '', + deviceIdentificator: event.deviceIdentificator, + deviceName: '', + type: event.type, + createdAt: DateTime.now().millisecondsSinceEpoch, + ); + + state = AsyncData( + current.copyWith( + notifications: [newNotification, ...current.notifications], + ), + ); + } + + Future loadMore() async { + final current = state.value; + if (current == null) return; + if (current.isLoadingMore || !current.hasMore) return; + + state = AsyncData(current.copyWith(isLoadingMore: true)); + + try { + final filter = ref.read(notificationsFilterProvider); + final nextPage = current.currentPage + 1; + final (notifications, totalPages) = await ref + .read(alertsRepositoryProvider) + .getAlerts(page: nextPage, pageSize: _pageSize, type: filter); + + state = AsyncData( + current.copyWith( + notifications: [...current.notifications, ...notifications], + currentPage: nextPage, + hasMore: nextPage < totalPages, + isLoadingMore: false, + ), + ); + } catch (_) { + state = AsyncData(current.copyWith(isLoadingMore: false)); + } + } +} diff --git a/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_feed_provider.g.dart b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_feed_provider.g.dart new file mode 100644 index 00000000..52e87356 --- /dev/null +++ b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_feed_provider.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notifications_feed_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(NotificationsFeed) +const notificationsFeedProvider = NotificationsFeedProvider._(); + +final class NotificationsFeedProvider + extends $AsyncNotifierProvider { + const NotificationsFeedProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'notificationsFeedProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$notificationsFeedHash(); + + @$internal + @override + NotificationsFeed create() => NotificationsFeed(); +} + +String _$notificationsFeedHash() => r'bb7878198548338c17db4b9ac51e3f88a24115d6'; + +abstract class _$NotificationsFeed + extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref + as $Ref, NotificationsFeedState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue, + NotificationsFeedState + >, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_filter_provider.dart b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_filter_provider.dart new file mode 100644 index 00000000..6e26b874 --- /dev/null +++ b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_filter_provider.dart @@ -0,0 +1,14 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'notifications_filter_provider.g.dart'; + +@riverpod +class NotificationsFilter extends _$NotificationsFilter { + @override + String? build() => null; + + void select(String? type) { + if (type == state) return; + state = type; + } +} diff --git a/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_filter_provider.g.dart b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_filter_provider.g.dart new file mode 100644 index 00000000..6626e662 --- /dev/null +++ b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/providers/notifications_filter_provider.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notifications_filter_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(NotificationsFilter) +const notificationsFilterProvider = NotificationsFilterProvider._(); + +final class NotificationsFilterProvider + extends $NotifierProvider { + const NotificationsFilterProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'notificationsFilterProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$notificationsFilterHash(); + + @$internal + @override + NotificationsFilter create() => NotificationsFilter(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(String? value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$notificationsFilterHash() => + r'3da0e65affd7f2c5121e19ab1625b5cfc14d3b3a'; + +abstract class _$NotificationsFilter extends $Notifier { + String? build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + String?, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/widgets/alert_card.dart b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/widgets/notification_card.dart similarity index 84% rename from modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/widgets/alert_card.dart rename to modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/widgets/notification_card.dart index 0287317f..e8666600 100644 --- a/modules/legacy/modules/control_panel/lib/src/features/alerts/presentation/widgets/alert_card.dart +++ b/modules/legacy/modules/control_panel/lib/src/features/notifications/presentation/widgets/notification_card.dart @@ -6,10 +6,10 @@ import 'package:utils/utils.dart'; import '../../../../core/domain/entities/alert_entity.dart'; -class AlertCard extends ConsumerWidget { - final AlertEntity alert; +class NotificationCard extends ConsumerWidget { + final AlertEntity notification; - const AlertCard({super.key, required this.alert}); + const NotificationCard({super.key, required this.notification}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -36,12 +36,13 @@ class AlertCard extends ConsumerWidget { width: SizeUtils.getByScreen(small: 40, big: 36), height: SizeUtils.getByScreen(small: 40, big: 36), decoration: BoxDecoration( - color: _alertColor(context, alert.type).withValues(alpha: 0.15), + color: _notificationColor(context, notification.type) + .withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), child: Icon( - _alertIcon(alert.type), - color: _alertColor(context, alert.type), + _notificationIcon(notification.type), + color: _notificationColor(context, notification.type), size: SizeUtils.getByScreen(small: 22, big: 20), ), ), @@ -51,7 +52,7 @@ class AlertCard extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _alertTypeLabel(context, alert.type), + _notificationTypeLabel(context, notification.type), style: TextStyle( fontSize: SizeUtils.getByScreen(small: 14, big: 13), fontWeight: FontWeight.w600, @@ -60,19 +61,19 @@ class AlertCard extends ConsumerWidget { ), SizedBox(height: 2), Text( - alert.deviceName.isNotEmpty - ? alert.deviceName - : alert.deviceIdentificator, + notification.deviceName.isNotEmpty + ? notification.deviceName + : notification.deviceIdentificator, style: TextStyle( fontSize: SizeUtils.getByScreen(small: 12, big: 11), color: Theme.of(context).colorScheme.onSurface .withValues(alpha: 0.6), ), ), - if (alert.geofenceAlert != null) ...[ + if (notification.geofenceAlert != null) ...[ SizedBox(height: 2), Text( - '${context.translate(I18n.alertGeofenceDetail)}: ${alert.geofenceAlert!.name}', + '${context.translate(I18n.alertGeofenceDetail)}: ${notification.geofenceAlert!.name}', style: TextStyle( fontSize: SizeUtils.getByScreen(small: 11, big: 10), color: primaryColor, @@ -83,7 +84,7 @@ class AlertCard extends ConsumerWidget { ), ), Text( - _timeAgo(context, alert.createdAt), + _timeAgo(context, notification.createdAt), style: TextStyle( fontSize: SizeUtils.getByScreen(small: 11, big: 10), color: Theme.of(context).colorScheme.onSurface @@ -96,7 +97,7 @@ class AlertCard extends ConsumerWidget { ); } - IconData _alertIcon(String type) { + IconData _notificationIcon(String type) { return switch (type) { 'sos' => Icons.warning_amber_rounded, 'falldown' => Icons.person_off, @@ -113,7 +114,7 @@ class AlertCard extends ConsumerWidget { }; } - Color _alertColor(BuildContext context, String type) { + Color _notificationColor(BuildContext context, String type) { return switch (type) { 'sos' => Theme.of(context).colorScheme.error, 'falldown' => Theme.of(context).colorScheme.error, @@ -130,7 +131,7 @@ class AlertCard extends ConsumerWidget { }; } - String _alertTypeLabel(BuildContext context, String type) { + String _notificationTypeLabel(BuildContext context, String type) { return switch (type) { 'sos' => context.translate(I18n.alertTypeSos), 'falldown' => context.translate(I18n.alertTypeFalldown), diff --git a/modules/legacy/modules/control_panel/pubspec.yaml b/modules/legacy/modules/control_panel/pubspec.yaml index 5218d6ee..5aa3367b 100644 --- a/modules/legacy/modules/control_panel/pubspec.yaml +++ b/modules/legacy/modules/control_panel/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: get_it: ^9.0.5 go_router: ^17.0.0 flutter_riverpod: ^3.0.3 + riverpod_annotation: ^3.0.3 freezed_annotation: ^3.1.0 freezed: ^3.2.3 dio: ^5.9.2 @@ -80,6 +81,7 @@ dev_dependencies: riverpod_generator: ^3.0.3 build_runner: ^2.7.1 riverpod_lint: ^3.0.3 + mocktail: ^1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/modules/legacy/modules/control_panel/test/features/notifications/notifications_feed_test.dart b/modules/legacy/modules/control_panel/test/features/notifications/notifications_feed_test.dart new file mode 100644 index 00000000..6915cabf --- /dev/null +++ b/modules/legacy/modules/control_panel/test/features/notifications/notifications_feed_test.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:control_panel/src/core/domain/entities/alert_entity.dart'; +import 'package:control_panel/src/core/domain/repositories/alerts_repository.dart'; +import 'package:control_panel/src/core/providers/alerts_repository_provider.dart'; +import 'package:control_panel/src/features/notifications/presentation/providers/notifications_feed_provider.dart'; +import 'package:control_panel/src/features/notifications/presentation/providers/notifications_filter_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:legacy_device_state/legacy_device_state.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:sf_infrastructure/sf_infrastructure.dart'; +import 'package:sf_shared/testing.dart'; + +class MockAlertsRepository extends Mock implements AlertsRepository {} + +class FakeWebSocketService implements WebSocketService { + final _controller = StreamController.broadcast(); + @override + Stream get events => _controller.stream; + + void emit(WebSocketEvent event) => _controller.add(event); + void close() => _controller.close(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final _alert1 = AlertEntity( + id: '1', + deviceIdentificator: 'd1', + deviceName: 'Watch', + type: 'sos', + createdAt: 1, +); +final _alert2 = AlertEntity( + id: '2', + deviceIdentificator: 'd1', + deviceName: 'Watch', + type: 'falldown', + createdAt: 2, +); + +void main() { + ProviderContainer buildContainer({ + required AlertsRepository repo, + required FakeWebSocketService ws, + }) { + return makeContainer( + overrides: [ + alertsRepositoryProvider.overrideWithValue(repo), + webSocketServiceProvider.overrideWithValue(ws), + ], + ); + } + + group('NotificationsFeed.build', () { + test('loads first page with no filter', () async { + final repo = MockAlertsRepository(); + final ws = FakeWebSocketService(); + when( + () => repo.getAlerts( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async => ([_alert1, _alert2], 2)); + + final container = buildContainer(repo: repo, ws: ws); + addTearDown(container.dispose); + addTearDown(ws.close); + + final state = + await container.read(notificationsFeedProvider.future); + + expect(state.notifications, [_alert1, _alert2]); + expect(state.currentPage, 1); + expect(state.hasMore, isTrue); + verify(() => repo.getAlerts(page: 1, pageSize: 20, type: null)) + .called(1); + }); + + test('refetches when filter changes', () async { + final repo = MockAlertsRepository(); + final ws = FakeWebSocketService(); + when( + () => repo.getAlerts( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async => ([_alert1], 1)); + + final container = buildContainer(repo: repo, ws: ws); + addTearDown(container.dispose); + addTearDown(ws.close); + + await container.read(notificationsFeedProvider.future); + + container.read(notificationsFilterProvider.notifier).select('sos'); + await container.read(notificationsFeedProvider.future); + + verify(() => repo.getAlerts(page: 1, pageSize: 20, type: 'sos')) + .called(1); + }); + }); + + group('NotificationsFeed.loadMore', () { + test('appends next page', () async { + final repo = MockAlertsRepository(); + final ws = FakeWebSocketService(); + when( + () => repo.getAlerts( + page: 1, + pageSize: any(named: 'pageSize'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async => ([_alert1], 2)); + when( + () => repo.getAlerts( + page: 2, + pageSize: any(named: 'pageSize'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async => ([_alert2], 2)); + + final container = buildContainer(repo: repo, ws: ws); + addTearDown(container.dispose); + addTearDown(ws.close); + + await container.read(notificationsFeedProvider.future); + await container.read(notificationsFeedProvider.notifier).loadMore(); + + final state = container.read(notificationsFeedProvider).value!; + expect(state.notifications, [_alert1, _alert2]); + expect(state.currentPage, 2); + expect(state.hasMore, isFalse); + }); + + test('no-op when hasMore is false', () async { + final repo = MockAlertsRepository(); + final ws = FakeWebSocketService(); + when( + () => repo.getAlerts( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async => ([_alert1], 1)); + + final container = buildContainer(repo: repo, ws: ws); + addTearDown(container.dispose); + addTearDown(ws.close); + + await container.read(notificationsFeedProvider.future); + await container.read(notificationsFeedProvider.notifier).loadMore(); + + verify(() => repo.getAlerts(page: 1, pageSize: 20, type: null)) + .called(1); + verifyNever(() => repo.getAlerts(page: 2, pageSize: 20, type: null)); + }); + }); + + group('NotificationsFeed WebSocket integration', () { + test('prepends new alert from WS when matching filter', () async { + final repo = MockAlertsRepository(); + final ws = FakeWebSocketService(); + when( + () => repo.getAlerts( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async => ([_alert1], 1)); + + final container = buildContainer(repo: repo, ws: ws); + addTearDown(container.dispose); + addTearDown(ws.close); + container.listen(notificationsFeedProvider, (_, __) {}); + + await container.read(notificationsFeedProvider.future); + ws.emit(AlertEvent( + type: 'sos', + deviceIdentificator: 'd1', + timestamp: '2026-04-22T00:00:00Z', + )); + await Future.delayed(Duration.zero); + + final state = container.read(notificationsFeedProvider).value!; + expect(state.notifications.length, 2); + expect(state.notifications.first.type, 'sos'); + }); + + test('drops new alert when filter excludes it', () async { + final repo = MockAlertsRepository(); + final ws = FakeWebSocketService(); + when( + () => repo.getAlerts( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + type: any(named: 'type'), + ), + ).thenAnswer((_) async => ([_alert1], 1)); + + final container = buildContainer(repo: repo, ws: ws); + addTearDown(container.dispose); + addTearDown(ws.close); + container.listen(notificationsFeedProvider, (_, __) {}); + + container.read(notificationsFilterProvider.notifier).select('sos'); + await container.read(notificationsFeedProvider.future); + + ws.emit(AlertEvent( + type: 'falldown', + deviceIdentificator: 'd1', + timestamp: '2026-04-22T00:00:00Z', + )); + await Future.delayed(Duration.zero); + + final state = container.read(notificationsFeedProvider).value!; + expect(state.notifications.length, 1); + }); + }); +} diff --git a/modules/legacy/modules/settings/lib/settings.dart b/modules/legacy/modules/settings/lib/settings.dart index b506cc56..2252b426 100644 --- a/modules/legacy/modules/settings/lib/settings.dart +++ b/modules/legacy/modules/settings/lib/settings.dart @@ -6,7 +6,6 @@ export 'src/features/battery/battery_builder.dart'; export 'src/features/block_phone/block_phone_builder.dart'; export 'src/features/disable_functions/disable_functions_builder.dart'; export 'src/features/language/language_builder.dart'; -export 'src/features/legacy_notifications/legacy_notifications_builder.dart'; export 'src/features/remote_management/remote_management_builder.dart'; export 'src/features/remote_on_off/remote_on_off_builder.dart'; export 'src/features/alerts/alerts_builder.dart'; diff --git a/modules/legacy/modules/settings/lib/src/features/legacy_notifications/legacy_notifications_builder.dart b/modules/legacy/modules/settings/lib/src/features/legacy_notifications/legacy_notifications_builder.dart deleted file mode 100644 index a23b0da4..00000000 --- a/modules/legacy/modules/settings/lib/src/features/legacy_notifications/legacy_notifications_builder.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -import 'presentation/legacy_notifications_screen.dart'; - -class LegacyNotificationsBuilder { - const LegacyNotificationsBuilder(); - - Page buildPage(BuildContext context, GoRouterState state) { - return MaterialPage( - key: state.pageKey, - child: const LegacyNotificationsScreen(), - ); - } -} diff --git a/modules/legacy/modules/settings/lib/src/features/legacy_notifications/presentation/legacy_notifications_screen.dart b/modules/legacy/modules/settings/lib/src/features/legacy_notifications/presentation/legacy_notifications_screen.dart deleted file mode 100644 index 7f1aab4e..00000000 --- a/modules/legacy/modules/settings/lib/src/features/legacy_notifications/presentation/legacy_notifications_screen.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:legacy_ui/legacy_ui.dart'; -import 'package:sf_localizations/sf_localizations.dart'; - -class LegacyNotificationsScreen extends ConsumerWidget { - const LegacyNotificationsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - - return LegacyPageLayout( - title: context.translate(I18n.notificationsLegacyTitle), - body: const Center(child: Text('Coming soon')), - ); - } -} diff --git a/modules/legacy/modules/settings/lib/src/features/settings/presentation/settings_screen.dart b/modules/legacy/modules/settings/lib/src/features/settings/presentation/settings_screen.dart index 36c44415..a6a92ef7 100644 --- a/modules/legacy/modules/settings/lib/src/features/settings/presentation/settings_screen.dart +++ b/modules/legacy/modules/settings/lib/src/features/settings/presentation/settings_screen.dart @@ -93,8 +93,8 @@ class SettingsScreen extends ConsumerWidget { _item( context, onPressed: () => - navigationContract.pushTo(AppRoutes.legacyNotifications), - icon: Icons.message_outlined, + navigationContract.pushTo(AppRoutes.deviceNotifications), + icon: Icons.notifications_outlined, text: I18n.notificationsLegacyTitle, color: color, ), diff --git a/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/wifi_current_network_provider.dart b/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/wifi_current_network_provider.dart index 1924f3de..29e3b1ac 100644 --- a/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/wifi_current_network_provider.dart +++ b/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/wifi_current_network_provider.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:legacy_device_state/legacy_device_state.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:settings/src/features/wifi_settings/domain/entities/wifi_network_entity.dart'; -import 'package:settings/src/features/wifi_settings/presentation/providers/web_socket_service_provider.dart'; import 'package:sf_infrastructure/sf_infrastructure.dart'; part 'wifi_current_network_provider.g.dart'; diff --git a/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/wifi_scan_provider.dart b/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/wifi_scan_provider.dart index f3d885f3..001974d9 100644 --- a/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/wifi_scan_provider.dart +++ b/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/wifi_scan_provider.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:legacy_device_state/legacy_device_state.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:settings/src/features/wifi_settings/domain/entities/scanned_wifi_network.dart'; -import 'package:settings/src/features/wifi_settings/presentation/providers/web_socket_service_provider.dart'; import 'package:sf_infrastructure/sf_infrastructure.dart'; part 'wifi_scan_provider.g.dart'; diff --git a/modules/legacy/modules/settings/test/features/wifi_settings/wifi_controller_test.dart b/modules/legacy/modules/settings/test/features/wifi_settings/wifi_controller_test.dart index 0c26752d..7c8f19d5 100644 --- a/modules/legacy/modules/settings/test/features/wifi_settings/wifi_controller_test.dart +++ b/modules/legacy/modules/settings/test/features/wifi_settings/wifi_controller_test.dart @@ -7,7 +7,6 @@ import 'package:mocktail/mocktail.dart'; import 'package:settings/src/core/domain/repositories/wifi_repository.dart'; import 'package:settings/src/core/providers/wifi_repository_provider.dart'; import 'package:settings/src/features/wifi_settings/domain/entities/wifi_network_entity.dart'; -import 'package:settings/src/features/wifi_settings/presentation/providers/web_socket_service_provider.dart'; import 'package:settings/src/features/wifi_settings/presentation/providers/wifi_controller.dart'; import 'package:sf_infrastructure/sf_infrastructure.dart'; import 'package:sf_shared/testing.dart'; diff --git a/modules/legacy/packages/legacy_device_state/lib/legacy_device_state.dart b/modules/legacy/packages/legacy_device_state/lib/legacy_device_state.dart index c7522ad6..af0c38a8 100644 --- a/modules/legacy/packages/legacy_device_state/lib/legacy_device_state.dart +++ b/modules/legacy/packages/legacy_device_state/lib/legacy_device_state.dart @@ -15,6 +15,7 @@ export 'src/data/repositories/commands_repository_impl.dart'; export 'src/domain/repositories/command_repository.dart'; export 'src/providers/commands_remote_datasource_provider.dart'; export 'src/providers/commands_repository_provider.dart'; +export 'src/providers/web_socket_service_provider.dart'; // Device settings update export 'src/data/datasources/device_settings_update_datasource.dart'; diff --git a/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/web_socket_service_provider.dart b/modules/legacy/packages/legacy_device_state/lib/src/providers/web_socket_service_provider.dart similarity index 100% rename from modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/web_socket_service_provider.dart rename to modules/legacy/packages/legacy_device_state/lib/src/providers/web_socket_service_provider.dart diff --git a/modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/web_socket_service_provider.g.dart b/modules/legacy/packages/legacy_device_state/lib/src/providers/web_socket_service_provider.g.dart similarity index 100% rename from modules/legacy/modules/settings/lib/src/features/wifi_settings/presentation/providers/web_socket_service_provider.g.dart rename to modules/legacy/packages/legacy_device_state/lib/src/providers/web_socket_service_provider.g.dart diff --git a/modules/legacy/packages/legacy_device_state/pubspec.yaml b/modules/legacy/packages/legacy_device_state/pubspec.yaml index 9cd9ddf4..53dc6ec0 100644 --- a/modules/legacy/packages/legacy_device_state/pubspec.yaml +++ b/modules/legacy/packages/legacy_device_state/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: utils: path: ../../../../packages/utils flutter_riverpod: ^3.0.3 + riverpod_annotation: ^3.0.3 freezed_annotation: ^3.1.0 json_annotation: ^4.9.0 dio: ^5.9.2 @@ -38,6 +39,7 @@ dev_dependencies: build_runner: ^2.7.1 freezed: ^3.2.3 json_serializable: ^6.11.2 + riverpod_generator: ^3.0.3 flutter: uses-material-design: true diff --git a/packages/navigation/lib/app_routes.dart b/packages/navigation/lib/app_routes.dart index 65624c50..b49a3800 100644 --- a/packages/navigation/lib/app_routes.dart +++ b/packages/navigation/lib/app_routes.dart @@ -54,7 +54,7 @@ class AppRoutes { static const controlPanel = '$legacyDashboard/control_panel'; static const customerService = '$controlPanel/customer_service'; - static const deviceAlertsNotifications = '$controlPanel/device_alerts_notifications'; + static const deviceNotifications = '$controlPanel/notifications'; static const deviceManagement = '$legacyDashboard/device_management'; static const legacyLocation = '$legacyDashboard/location'; @@ -98,7 +98,6 @@ class AppRoutes { static const blockPhone = '$settings/block_phone'; static const disableFunctions = '$settings/disable_functions'; static const language = '$settings/language'; - static const legacyNotifications = '$settings/legacy_notifications'; static const remoteManagement = '$settings/remote_management'; static const remoteOnOff = '$settings/remote_on_off'; static const alerts = '$settings/alerts';