fix(router): rename duplicate notifications route name

This commit is contained in:
2026-04-22 20:21:35 +02:00
parent 221d053d5f
commit df92c51344
28 changed files with 559 additions and 551 deletions

View File

@@ -169,7 +169,7 @@ void _handleNotificationNavigation(Map<String, dynamic> data) {
switch (command) {
case 'ALERT':
appRouter.go(AppRoutes.deviceAlertsNotifications);
appRouter.go(AppRoutes.deviceNotifications);
default:
debugPrint('[Notification] unhandled command: $command');
}

View File

@@ -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',

View File

@@ -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';

View File

@@ -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, AlertsViewState>(
AlertsViewModel.new,
);
class AlertsViewModel extends Notifier<AlertsViewState> {
late final AlertsRepository _repository;
late final WebSocketService _webSocket;
StreamSubscription<WebSocketEvent>? _webSocketSubscription;
static const int _pageSize = 20;
@override
AlertsViewState build() {
_repository = ref.read(alertsRepositoryProvider);
_webSocket = GetIt.I<WebSocketService>();
_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<void> _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<void> 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);
}
}

View File

@@ -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<AlertEntity> alerts,
@Default(true) bool isLoading,
@Default(false) bool isLoadingMore,
@Default(1) int currentPage,
@Default(false) bool hasMore,
String? selectedType,
AlertsError? error,
}) = _AlertsViewState;
}

View File

@@ -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>(T value) => value;
/// @nodoc
mixin _$AlertsViewState {
List<AlertEntity> 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<AlertsViewState> get copyWith => _$AlertsViewStateCopyWithImpl<AlertsViewState>(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<AlertEntity> 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<AlertEntity>,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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( List<AlertEntity> 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 extends Object?>(TResult Function( List<AlertEntity> 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 extends Object?>(TResult? Function( List<AlertEntity> 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<AlertEntity> alerts = const [], this.isLoading = true, this.isLoadingMore = false, this.currentPage = 1, this.hasMore = false, this.selectedType, this.error}): _alerts = alerts;
final List<AlertEntity> _alerts;
@override@JsonKey() List<AlertEntity> 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<AlertEntity> 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<AlertEntity>,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

View File

@@ -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),
),
),
),
],
);
}

View File

@@ -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<void> buildPage(BuildContext context, GoRouterState state) {
final NavigationContract navigationContract = GetIt.I<NavigationContract>();
return MaterialPage<void>(
key: state.pageKey,
child: AlertsScreen(navigationContract: navigationContract),
child: NotificationsScreen(navigationContract: navigationContract),
);
}
}

View File

@@ -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<String?> 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<AlertEntity> 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]);
},
);
}

View File

@@ -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<AlertEntity> notifications;
final int currentPage;
final bool hasMore;
final bool isLoadingMore;
NotificationsFeedState copyWith({
List<AlertEntity>? 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<WebSocketEvent>? _subscription;
@override
Future<NotificationsFeedState> 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<void> 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));
}
}
}

View File

@@ -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<NotificationsFeed, NotificationsFeedState> {
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<NotificationsFeedState> {
FutureOr<NotificationsFeedState> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref =
this.ref
as $Ref<AsyncValue<NotificationsFeedState>, NotificationsFeedState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<NotificationsFeedState>,
NotificationsFeedState
>,
AsyncValue<NotificationsFeedState>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -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;
}
}

View File

@@ -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<NotificationsFilter, String?> {
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<String?>(value),
);
}
}
String _$notificationsFilterHash() =>
r'3da0e65affd7f2c5121e19ab1625b5cfc14d3b3a';
abstract class _$NotificationsFilter extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -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),

View File

@@ -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

View File

@@ -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<WebSocketEvent>.broadcast();
@override
Stream<WebSocketEvent> 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<void>.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<void>.delayed(Duration.zero);
final state = container.read(notificationsFeedProvider).value!;
expect(state.notifications.length, 1);
});
});
}

View File

@@ -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';

View File

@@ -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<void> buildPage(BuildContext context, GoRouterState state) {
return MaterialPage<void>(
key: state.pageKey,
child: const LegacyNotificationsScreen(),
);
}
}

View File

@@ -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')),
);
}
}

View File

@@ -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,
),

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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

View File

@@ -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';