refactor(notifications): AlertType enum + freezed feed family + subroute by type

This commit is contained in:
2026-05-13 22:35:14 -05:00
parent bc56362d45
commit ea554e2ec8
23 changed files with 735 additions and 716 deletions

View File

@@ -101,6 +101,14 @@ void configureAppRouter() {
path: 'notifications',
name: 'legacy_notifications',
pageBuilder: const DeviceNotificationsBuilder().buildPage,
routes: [
GoRoute(
path: ':type',
name: 'legacy_notifications_by_type',
pageBuilder:
const FilteredNotificationsBuilder().buildPage,
),
],
),
GoRoute(
path: 'account_settings',

View File

@@ -12,6 +12,7 @@ export 'src/features/remote_on_off/remote_on_off_builder.dart';
export 'src/features/alerts/alerts_builder.dart';
export 'src/features/notifications/notifications_builder.dart'
show DeviceNotificationsBuilder;
export 'src/features/notifications/filtered/filtered_notifications_builder.dart';
export 'src/features/sos_contacts/sos_contacts_builder.dart';
export 'src/features/sound/sound_builder.dart';
export 'src/features/sync_clock/sync_clock_builder.dart';

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:sf_localizations/sf_localizations.dart';
enum AlertType {
sos('sos', I18n.alertTypeSos, Icons.warning_amber_rounded),
falldown('falldown', I18n.alertTypeFalldown, Icons.personal_injury),
lowBattery('lowBattery', I18n.alertTypeLowBattery, Icons.battery_alert),
disconnect('disconnect', I18n.alertTypeDisconnect, Icons.link_off),
reconnected('reconnect', I18n.alertTypeReconnected, Icons.wifi),
geofenceIn('geofenceIn', I18n.alertTypeGeofenceIn, Icons.location_on),
geofenceOut('geofenceOut', I18n.alertTypeGeofenceOut, Icons.location_off),
braceletRemoved(
'braceletRemoved',
I18n.alertTypeBraceletRemoved,
Icons.watch_off,
),
abnormalHeartRate(
'abnormalHeartRate',
I18n.alertTypeAbnormalHeartRate,
Icons.heart_broken,
),
standstill(
'standstill',
I18n.alertTypeStandstill,
Icons.accessibility_new,
),
movement('movement', I18n.alertTypeMovement, Icons.directions_walk);
const AlertType(this.apiKey, this.labelKey, this.icon);
final String apiKey;
final String labelKey;
final IconData icon;
Color color(BuildContext context) => switch (this) {
AlertType.sos => Theme.of(context).colorScheme.error,
AlertType.falldown => Theme.of(context).colorScheme.error,
AlertType.abnormalHeartRate => Theme.of(context).colorScheme.error,
AlertType.lowBattery => Colors.orange,
AlertType.disconnect => Colors.grey,
AlertType.reconnected => Colors.green,
AlertType.braceletRemoved => Colors.deepOrange,
AlertType.standstill => Colors.amber,
AlertType.geofenceIn => Colors.blue,
AlertType.geofenceOut => Colors.indigo,
AlertType.movement => Colors.teal,
};
static AlertType? fromApiKey(String key) {
for (final value in AlertType.values) {
if (value.apiKey == key) return value;
}
return null;
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:settings/src/core/domain/entities/alert_type.dart';
import 'package:settings/src/features/notifications/filtered/presentation/filtered_notifications_screen.dart';
class FilteredNotificationsBuilder {
const FilteredNotificationsBuilder();
Page<void> buildPage(BuildContext context, GoRouterState state) {
final raw = state.pathParameters['type'] ?? '';
final type = AlertType.fromApiKey(raw);
return MaterialPage<void>(
key: state.pageKey,
child: type == null
? const _UnknownTypeRedirect()
: FilteredNotificationsScreen(type: type),
);
}
}
class _UnknownTypeRedirect extends StatelessWidget {
const _UnknownTypeRedirect();
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) Navigator.of(context).pop();
});
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,150 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:legacy_ui/legacy_ui.dart';
import 'package:settings/src/core/domain/entities/alert_type.dart';
import 'package:settings/src/core/domain/entities/notification_entity.dart';
import 'package:settings/src/features/notifications/presentation/providers/notifications_feed_provider.dart';
import 'package:settings/src/features/notifications/presentation/widgets/notification_card.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:utils/utils.dart';
class FilteredNotificationsScreen extends ConsumerWidget {
final AlertType type;
const FilteredNotificationsScreen({super.key, required this.type});
@override
Widget build(BuildContext context, WidgetRef ref) {
final feedAsync = ref.watch(notificationsFeedProvider(type));
ref.listen(notificationsFeedProvider(type), (prev, next) async {
if (next.hasError && prev?.hasError != true) {
await showErrorDialog(context, I18n.alertsLoadError);
}
});
return LegacyPageLayout(
title: context.translate(type.labelKey),
body: feedAsync.when(
loading: () => const LegacyLoadingIndicator(),
error: (_, __) =>
Center(child: Text(context.translate(I18n.alertsLoadError))),
data: (feed) => _NotificationsList(
type: type,
notifications: feed.notifications,
isLoadingMore: feed.isLoadingMore,
hasMore: feed.hasMore,
),
),
);
}
}
class _NotificationsList extends ConsumerStatefulWidget {
final AlertType type;
final List<NotificationEntity> notifications;
final bool isLoadingMore;
final bool hasMore;
const _NotificationsList({
required this.type,
required this.notifications,
required this.isLoadingMore,
required this.hasMore,
});
@override
ConsumerState<_NotificationsList> createState() => _NotificationsListState();
}
class _NotificationsListState extends ConsumerState<_NotificationsList> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) => _ensureViewportFilled());
}
@override
void didUpdateWidget(covariant _NotificationsList oldWidget) {
super.didUpdateWidget(oldWidget);
WidgetsBinding.instance.addPostFrameCallback((_) => _ensureViewportFilled());
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_triggerLoadMore();
}
}
void _ensureViewportFilled() {
if (!mounted || !_scrollController.hasClients) return;
if (!widget.hasMore || widget.isLoadingMore) return;
if (_scrollController.position.maxScrollExtent <= 0) {
_triggerLoadMore();
}
}
void _triggerLoadMore() {
ref.read(notificationsFeedProvider(widget.type).notifier).loadMore();
}
@override
Widget build(BuildContext context) {
final notifications = widget.notifications;
if (notifications.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.notifications_none,
color: context.sfColors.legacyPrimary,
size: SizeUtils.getByScreen(small: 80, big: 90),
),
SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)),
Text(
context.translate(I18n.alertsEmpty),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 16, big: 17),
color: Colors.grey,
),
),
],
),
);
}
return ListView.builder(
controller: _scrollController,
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 16, big: 14),
vertical: SizeUtils.getByScreen(small: 8, big: 6),
),
itemCount: notifications.length + (widget.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == notifications.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return NotificationCard(notification: notifications[index]);
},
);
}
}

View File

@@ -1,19 +1,14 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:navigation/navigation.dart';
import 'presentation/notifications_screen.dart';
import 'package:settings/src/features/notifications/presentation/notifications_screen.dart';
class DeviceNotificationsBuilder {
const DeviceNotificationsBuilder();
Page<void> buildPage(BuildContext context, GoRouterState state) {
final NavigationContract navigationContract = GetIt.I<NavigationContract>();
return MaterialPage<void>(
key: state.pageKey,
child: NotificationsScreen(navigationContract: navigationContract),
child: const NotificationsScreen(),
);
}
}

View File

@@ -1,84 +1,48 @@
import 'package:settings/src/core/domain/entities/notification_entity.dart';
import 'package:settings/src/features/notifications/presentation/providers/notifications_feed_provider.dart';
import 'package:settings/src/features/notifications/presentation/providers/notifications_filter_provider.dart';
import 'package:settings/src/features/notifications/presentation/widgets/notification_card.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:legacy_ui/legacy_ui.dart';
import 'package:navigation/navigation.dart';
import 'package:settings/src/core/domain/entities/alert_type.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:utils/utils.dart';
class NotificationsScreen extends ConsumerWidget {
final NavigationContract navigationContract;
const NotificationsScreen({super.key, required this.navigationContract});
const NotificationsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final primaryColor = context.sfColors.legacyPrimary;
final selectedType = ref.watch(notificationsFilterProvider);
final device = ref.watch(selectedDeviceProvider).value;
final supported = _supportedTypes(device?.capabilities?.alerts?.types);
if (selectedType != null) {
return _FilteredNotificationsScreen(
type: selectedType,
onBack: () =>
ref.read(notificationsFilterProvider.notifier).select(null),
);
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: true,
automaticallyImplyLeading: false,
leading: IconButton(
onPressed: () => navigationContract.goBack(),
icon: Icon(
Icons.adaptive.arrow_back,
color: primaryColor,
size: SizeUtils.getByScreen(small: 32, big: 28),
),
),
title: Text(
context.translate(I18n.notificationsLegacyTitle).toUpperCase(),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 20, big: 19),
fontWeight: FontWeight.w500,
letterSpacing: 0,
color: primaryColor,
),
),
),
return LegacyPageLayout(
title: context.translate(I18n.notificationsLegacyTitle),
body: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 14, big: 12),
vertical: SizeUtils.getByScreen(small: 12, big: 10),
),
child: Column(
children: _notificationTypes.map((entry) {
children: supported.map((type) {
return Padding(
padding: EdgeInsets.only(
bottom: SizeUtils.getByScreen(small: 8, big: 7),
),
child: SectionButton(
onPressed: () => ref
.read(notificationsFilterProvider.notifier)
.select(entry.type),
onPressed: () => context.push(
AppRoutes.deviceNotificationsByType(type.apiKey),
),
icon: Icon(
entry.icon,
type.icon,
size: SizeUtils.getByScreen(small: 34, big: 38),
color: primaryColor,
),
iconPadding: SizeUtils.getByScreen(small: 14, big: 12),
body: Text(
context.translate(entry.labelKey),
context.translate(type.labelKey),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 16, big: 17),
fontWeight: FontWeight.w500,
@@ -91,225 +55,13 @@ class NotificationsScreen extends ConsumerWidget {
),
);
}
}
class _NotificationType {
final String type;
final String labelKey;
final IconData icon;
const _NotificationType({
required this.type,
required this.labelKey,
required this.icon,
});
}
const _notificationTypes = [
_NotificationType(
type: 'sos',
labelKey: I18n.alertTypeSos,
icon: Icons.sos,
),
_NotificationType(
type: 'falldown',
labelKey: I18n.alertTypeFalldown,
icon: Icons.personal_injury,
),
_NotificationType(
type: 'lowBattery',
labelKey: I18n.alertTypeLowBattery,
icon: Icons.battery_alert,
),
_NotificationType(
type: 'disconnect',
labelKey: I18n.alertTypeDisconnect,
icon: Icons.link_off,
),
_NotificationType(
type: 'geofenceIn',
labelKey: I18n.alertTypeGeofenceIn,
icon: Icons.location_on,
),
_NotificationType(
type: 'geofenceOut',
labelKey: I18n.alertTypeGeofenceOut,
icon: Icons.location_off,
),
_NotificationType(
type: 'braceletRemoved',
labelKey: I18n.alertTypeBraceletRemoved,
icon: Icons.watch_off,
),
_NotificationType(
type: 'abnormalHeartRate',
labelKey: I18n.alertTypeAbnormalHeartRate,
icon: Icons.heart_broken,
),
_NotificationType(
type: 'standstill',
labelKey: I18n.alertTypeStandstill,
icon: Icons.accessibility_new,
),
_NotificationType(
type: 'movement',
labelKey: I18n.alertTypeMovement,
icon: Icons.directions_walk,
),
];
class _FilteredNotificationsScreen extends ConsumerWidget {
final String type;
final VoidCallback onBack;
const _FilteredNotificationsScreen({
required this.type,
required this.onBack,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final primaryColor = context.sfColors.legacyPrimary;
final feedAsync = ref.watch(notificationsFeedProvider);
final typeEntry = _notificationTypes
.where((e) => e.type == type)
.firstOrNull;
final title = typeEntry != null
? context.translate(typeEntry.labelKey)
: type;
ref.listen(notificationsFeedProvider, (prev, next) async {
if (next.hasError && prev?.hasError != true) {
await showErrorDialog(context, I18n.alertsLoadError);
}
});
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) onBack();
},
child: Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: true,
automaticallyImplyLeading: false,
leading: IconButton(
onPressed: onBack,
icon: Icon(
Icons.adaptive.arrow_back,
color: primaryColor,
size: SizeUtils.getByScreen(small: 32, big: 28),
),
),
title: Text(
title.toUpperCase(),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 20, big: 19),
fontWeight: FontWeight.w500,
letterSpacing: 0,
color: primaryColor,
),
),
),
body: feedAsync.when(
loading: () => const LegacyLoadingIndicator(),
error: (_, __) => Center(
child: Text(context.translate(I18n.alertsLoadError)),
),
data: (feed) => _NotificationsList(
notifications: feed.notifications,
isLoadingMore: feed.isLoadingMore,
),
),
),
);
}
}
class _NotificationsList extends ConsumerStatefulWidget {
final List<NotificationEntity> notifications;
final bool isLoadingMore;
const _NotificationsList({
required this.notifications,
required this.isLoadingMore,
});
@override
ConsumerState<_NotificationsList> createState() =>
_NotificationsListState();
}
class _NotificationsListState extends ConsumerState<_NotificationsList> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(notificationsFeedProvider.notifier).loadMore();
List<AlertType> _supportedTypes(List<String>? deviceAlertTypes) {
if (deviceAlertTypes == null || deviceAlertTypes.isEmpty) {
return AlertType.values;
}
}
@override
Widget build(BuildContext context) {
final notifications = widget.notifications;
if (notifications.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.notifications_none,
color: context.sfColors.legacyPrimary,
size: SizeUtils.getByScreen(small: 80, big: 90),
),
SizedBox(height: SizeUtils.getByScreen(small: 16, big: 18)),
Text(
context.translate(I18n.alertsEmpty),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 16, big: 17),
color: Colors.grey,
),
),
],
),
);
}
return ListView.builder(
controller: _scrollController,
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 16, big: 14),
vertical: SizeUtils.getByScreen(small: 8, big: 6),
),
itemCount: notifications.length + (widget.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == notifications.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return NotificationCard(notification: notifications[index]);
},
);
return AlertType.values
.where((t) => deviceAlertTypes.contains(t.apiKey))
.toList();
}
}

View File

@@ -1,60 +1,28 @@
import 'dart:async';
import 'package:settings/src/core/domain/entities/notification_entity.dart';
import 'package:settings/src/core/providers/notifications_repository_provider.dart';
import 'package:settings/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:settings/src/core/domain/entities/alert_type.dart';
import 'package:settings/src/core/domain/entities/notification_entity.dart';
import 'package:settings/src/core/providers/notifications_repository_provider.dart';
import 'package:settings/src/features/notifications/presentation/providers/notifications_feed_state.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<NotificationEntity> notifications;
final int currentPage;
final bool hasMore;
final bool isLoadingMore;
NotificationsFeedState copyWith({
List<NotificationEntity>? 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());
Future<NotificationsFeedState> build(AlertType type) async {
final subscription = ref
.read(webSocketServiceProvider)
.events
.listen(_onEvent);
ref.onDispose(subscription.cancel);
final (notifications, totalPages) = await ref
.read(notificationsRepositoryProvider)
.getNotifications(page: 1, pageSize: _pageSize, type: filter);
.getNotifications(page: 1, pageSize: _pageSize, type: type.apiKey);
return NotificationsFeedState(
notifications: notifications,
@@ -65,24 +33,23 @@ class NotificationsFeed extends _$NotificationsFeed {
void _onEvent(WebSocketEvent event) {
if (event is! AlertEvent) return;
if (event.type != type.apiKey) return;
final current = state.value;
if (current == null) return;
final filter = ref.read(notificationsFilterProvider);
if (filter != null && filter != event.type) return;
final newNotification = NotificationEntity(
id: '',
final now = DateTime.now().millisecondsSinceEpoch;
final injected = NotificationEntity(
id: 'ws-${event.deviceIdentificator}-${event.type}-$now',
deviceIdentificator: event.deviceIdentificator,
deviceName: '',
type: event.type,
createdAt: DateTime.now().millisecondsSinceEpoch,
createdAt: now,
);
state = AsyncData(
current.copyWith(
notifications: [newNotification, ...current.notifications],
notifications: [injected, ...current.notifications],
),
);
}
@@ -95,11 +62,14 @@ class NotificationsFeed extends _$NotificationsFeed {
state = AsyncData(current.copyWith(isLoadingMore: true));
try {
final filter = ref.read(notificationsFilterProvider);
final nextPage = current.currentPage + 1;
final (notifications, totalPages) = await ref
.read(notificationsRepositoryProvider)
.getNotifications(page: nextPage, pageSize: _pageSize, type: filter);
.getNotifications(
page: nextPage,
pageSize: _pageSize,
type: type.apiKey,
);
state = AsyncData(
current.copyWith(
@@ -110,7 +80,9 @@ class NotificationsFeed extends _$NotificationsFeed {
),
);
} catch (_) {
state = AsyncData(current.copyWith(isLoadingMore: false));
state = AsyncData(
current.copyWith(isLoadingMore: false, hasMore: false),
);
}
}
}

View File

@@ -10,38 +10,83 @@ part of 'notifications_feed_provider.dart';
// ignore_for_file: type=lint, type=warning
@ProviderFor(NotificationsFeed)
const notificationsFeedProvider = NotificationsFeedProvider._();
const notificationsFeedProvider = NotificationsFeedFamily._();
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,
);
const NotificationsFeedProvider._({
required NotificationsFeedFamily super.from,
required AlertType super.argument,
}) : super(
retry: null,
name: r'notificationsFeedProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$notificationsFeedHash();
@override
String toString() {
return r'notificationsFeedProvider'
''
'($argument)';
}
@$internal
@override
NotificationsFeed create() => NotificationsFeed();
@override
bool operator ==(Object other) {
return other is NotificationsFeedProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$notificationsFeedHash() => r'907cd9e444a625ff38206809797a006321e1ed99';
String _$notificationsFeedHash() => r'824050171b434b1951237e0b58a0e135fe8e4785';
final class NotificationsFeedFamily extends $Family
with
$ClassFamilyOverride<
NotificationsFeed,
AsyncValue<NotificationsFeedState>,
NotificationsFeedState,
FutureOr<NotificationsFeedState>,
AlertType
> {
const NotificationsFeedFamily._()
: super(
retry: null,
name: r'notificationsFeedProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
NotificationsFeedProvider call(AlertType type) =>
NotificationsFeedProvider._(argument: type, from: this);
@override
String toString() => r'notificationsFeedProvider';
}
abstract class _$NotificationsFeed
extends $AsyncNotifier<NotificationsFeedState> {
FutureOr<NotificationsFeedState> build();
late final _$args = ref.$arg as AlertType;
AlertType get type => _$args;
FutureOr<NotificationsFeedState> build(AlertType type);
@$mustCallSuper
@override
void runBuild() {
final created = build();
final created = build(_$args);
final ref =
this.ref
as $Ref<AsyncValue<NotificationsFeedState>, NotificationsFeedState>;

View File

@@ -0,0 +1,14 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:settings/src/core/domain/entities/notification_entity.dart';
part 'notifications_feed_state.freezed.dart';
@freezed
abstract class NotificationsFeedState with _$NotificationsFeedState {
const factory NotificationsFeedState({
@Default([]) List<NotificationEntity> notifications,
@Default(1) int currentPage,
@Default(false) bool hasMore,
@Default(false) bool isLoadingMore,
}) = _NotificationsFeedState;
}

View File

@@ -0,0 +1,286 @@
// 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 'notifications_feed_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$NotificationsFeedState {
List<NotificationEntity> get notifications; int get currentPage; bool get hasMore; bool get isLoadingMore;
/// Create a copy of NotificationsFeedState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$NotificationsFeedStateCopyWith<NotificationsFeedState> get copyWith => _$NotificationsFeedStateCopyWithImpl<NotificationsFeedState>(this as NotificationsFeedState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is NotificationsFeedState&&const DeepCollectionEquality().equals(other.notifications, notifications)&&(identical(other.currentPage, currentPage) || other.currentPage == currentPage)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(notifications),currentPage,hasMore,isLoadingMore);
@override
String toString() {
return 'NotificationsFeedState(notifications: $notifications, currentPage: $currentPage, hasMore: $hasMore, isLoadingMore: $isLoadingMore)';
}
}
/// @nodoc
abstract mixin class $NotificationsFeedStateCopyWith<$Res> {
factory $NotificationsFeedStateCopyWith(NotificationsFeedState value, $Res Function(NotificationsFeedState) _then) = _$NotificationsFeedStateCopyWithImpl;
@useResult
$Res call({
List<NotificationEntity> notifications, int currentPage, bool hasMore, bool isLoadingMore
});
}
/// @nodoc
class _$NotificationsFeedStateCopyWithImpl<$Res>
implements $NotificationsFeedStateCopyWith<$Res> {
_$NotificationsFeedStateCopyWithImpl(this._self, this._then);
final NotificationsFeedState _self;
final $Res Function(NotificationsFeedState) _then;
/// Create a copy of NotificationsFeedState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? notifications = null,Object? currentPage = null,Object? hasMore = null,Object? isLoadingMore = null,}) {
return _then(_self.copyWith(
notifications: null == notifications ? _self.notifications : notifications // ignore: cast_nullable_to_non_nullable
as List<NotificationEntity>,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,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [NotificationsFeedState].
extension NotificationsFeedStatePatterns on NotificationsFeedState {
/// 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( _NotificationsFeedState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _NotificationsFeedState() 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( _NotificationsFeedState value) $default,){
final _that = this;
switch (_that) {
case _NotificationsFeedState():
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( _NotificationsFeedState value)? $default,){
final _that = this;
switch (_that) {
case _NotificationsFeedState() 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<NotificationEntity> notifications, int currentPage, bool hasMore, bool isLoadingMore)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _NotificationsFeedState() when $default != null:
return $default(_that.notifications,_that.currentPage,_that.hasMore,_that.isLoadingMore);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<NotificationEntity> notifications, int currentPage, bool hasMore, bool isLoadingMore) $default,) {final _that = this;
switch (_that) {
case _NotificationsFeedState():
return $default(_that.notifications,_that.currentPage,_that.hasMore,_that.isLoadingMore);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<NotificationEntity> notifications, int currentPage, bool hasMore, bool isLoadingMore)? $default,) {final _that = this;
switch (_that) {
case _NotificationsFeedState() when $default != null:
return $default(_that.notifications,_that.currentPage,_that.hasMore,_that.isLoadingMore);case _:
return null;
}
}
}
/// @nodoc
class _NotificationsFeedState implements NotificationsFeedState {
const _NotificationsFeedState({final List<NotificationEntity> notifications = const [], this.currentPage = 1, this.hasMore = false, this.isLoadingMore = false}): _notifications = notifications;
final List<NotificationEntity> _notifications;
@override@JsonKey() List<NotificationEntity> get notifications {
if (_notifications is EqualUnmodifiableListView) return _notifications;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_notifications);
}
@override@JsonKey() final int currentPage;
@override@JsonKey() final bool hasMore;
@override@JsonKey() final bool isLoadingMore;
/// Create a copy of NotificationsFeedState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$NotificationsFeedStateCopyWith<_NotificationsFeedState> get copyWith => __$NotificationsFeedStateCopyWithImpl<_NotificationsFeedState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _NotificationsFeedState&&const DeepCollectionEquality().equals(other._notifications, _notifications)&&(identical(other.currentPage, currentPage) || other.currentPage == currentPage)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_notifications),currentPage,hasMore,isLoadingMore);
@override
String toString() {
return 'NotificationsFeedState(notifications: $notifications, currentPage: $currentPage, hasMore: $hasMore, isLoadingMore: $isLoadingMore)';
}
}
/// @nodoc
abstract mixin class _$NotificationsFeedStateCopyWith<$Res> implements $NotificationsFeedStateCopyWith<$Res> {
factory _$NotificationsFeedStateCopyWith(_NotificationsFeedState value, $Res Function(_NotificationsFeedState) _then) = __$NotificationsFeedStateCopyWithImpl;
@override @useResult
$Res call({
List<NotificationEntity> notifications, int currentPage, bool hasMore, bool isLoadingMore
});
}
/// @nodoc
class __$NotificationsFeedStateCopyWithImpl<$Res>
implements _$NotificationsFeedStateCopyWith<$Res> {
__$NotificationsFeedStateCopyWithImpl(this._self, this._then);
final _NotificationsFeedState _self;
final $Res Function(_NotificationsFeedState) _then;
/// Create a copy of NotificationsFeedState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? notifications = null,Object? currentPage = null,Object? hasMore = null,Object? isLoadingMore = null,}) {
return _then(_NotificationsFeedState(
notifications: null == notifications ? _self._notifications : notifications // ignore: cast_nullable_to_non_nullable
as List<NotificationEntity>,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,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -1,14 +0,0 @@
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

@@ -1,64 +0,0 @@
// 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

@@ -1,11 +1,11 @@
import 'package:legacy_theme/legacy_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:settings/src/core/domain/entities/alert_type.dart';
import 'package:settings/src/core/domain/entities/notification_entity.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:utils/utils.dart';
import '../../../../core/domain/entities/notification_entity.dart';
class NotificationCard extends ConsumerWidget {
final NotificationEntity notification;
@@ -14,11 +14,15 @@ class NotificationCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final primaryColor = context.sfColors.legacyPrimary;
final type = AlertType.fromApiKey(notification.type);
final accent = type?.color(context) ?? Colors.grey;
final label = type != null
? context.translate(type.labelKey)
: notification.type;
final iconData = type?.icon ?? Icons.notifications;
return Padding(
padding: EdgeInsets.only(
bottom: SizeUtils.getByScreen(small: 8, big: 6),
),
padding: EdgeInsets.only(bottom: SizeUtils.getByScreen(small: 8, big: 6)),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen(small: 14, big: 12),
@@ -36,13 +40,12 @@ class NotificationCard extends ConsumerWidget {
width: SizeUtils.getByScreen(small: 40, big: 36),
height: SizeUtils.getByScreen(small: 40, big: 36),
decoration: BoxDecoration(
color: _notificationColor(context, notification.type)
.withValues(alpha: 0.15),
color: accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
_notificationIcon(notification.type),
color: _notificationColor(context, notification.type),
iconData,
color: accent,
size: SizeUtils.getByScreen(small: 22, big: 20),
),
),
@@ -52,26 +55,27 @@ class NotificationCard extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_notificationTypeLabel(context, notification.type),
label,
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 14, big: 13),
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
SizedBox(height: 2),
const SizedBox(height: 2),
Text(
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),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
if (notification.geofenceAlert != null) ...[
SizedBox(height: 2),
const SizedBox(height: 2),
Text(
'${context.translate(I18n.alertGeofenceDetail)}: ${notification.geofenceAlert!.name}',
style: TextStyle(
@@ -87,8 +91,9 @@ class NotificationCard extends ConsumerWidget {
_timeAgo(context, notification.createdAt),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 11, big: 10),
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.4),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.4),
),
),
],
@@ -97,66 +102,35 @@ class NotificationCard extends ConsumerWidget {
);
}
IconData _notificationIcon(String type) {
return switch (type) {
'sos' => Icons.warning_amber_rounded,
'falldown' => Icons.person_off,
'lowBattery' => Icons.battery_alert,
'disconnect' => Icons.wifi_off,
'reconnected' => Icons.wifi,
'braceletRemoved' => Icons.watch_off,
'standstill' => Icons.accessibility_new,
'abnormalHeartRate' => Icons.heart_broken,
'geofenceIn' => Icons.login,
'geofenceOut' => Icons.logout,
'movement' => Icons.directions_walk,
_ => Icons.notifications,
};
}
Color _notificationColor(BuildContext context, String type) {
return switch (type) {
'sos' => Theme.of(context).colorScheme.error,
'falldown' => Theme.of(context).colorScheme.error,
'lowBattery' => Colors.orange,
'disconnect' => Colors.grey,
'reconnected' => Colors.green,
'braceletRemoved' => Colors.deepOrange,
'standstill' => Colors.amber,
'abnormalHeartRate' => Theme.of(context).colorScheme.error,
'geofenceIn' => Colors.blue,
'geofenceOut' => Colors.indigo,
'movement' => Colors.teal,
_ => Colors.grey,
};
}
String _notificationTypeLabel(BuildContext context, String type) {
return switch (type) {
'sos' => context.translate(I18n.alertTypeSos),
'falldown' => context.translate(I18n.alertTypeFalldown),
'lowBattery' => context.translate(I18n.alertTypeLowBattery),
'disconnect' => context.translate(I18n.alertTypeDisconnect),
'reconnected' => context.translate(I18n.alertTypeReconnected),
'braceletRemoved' => context.translate(I18n.alertTypeBraceletRemoved),
'standstill' => context.translate(I18n.alertTypeStandstill),
'abnormalHeartRate' => context.translate(I18n.alertTypeAbnormalHeartRate),
'geofenceIn' => context.translate(I18n.alertTypeGeofenceIn),
'geofenceOut' => context.translate(I18n.alertTypeGeofenceOut),
'movement' => context.translate(I18n.alertTypeMovement),
_ => type,
};
}
String _timeAgo(BuildContext context, int timestampMs) {
final now = DateTime.now();
final date = DateTime.fromMillisecondsSinceEpoch(timestampMs);
final difference = now.difference(date);
if (difference.inMinutes < 1) return 'ahora';
if (difference.inMinutes < 60) return '${difference.inMinutes} min';
if (difference.inHours < 24) return '${difference.inHours}h';
if (difference.inDays < 30) return '${difference.inDays}d';
return '${(difference.inDays / 30).floor()}m';
if (difference.inMinutes < 1) {
return context.translate(I18n.timeAgoNow);
}
if (difference.inMinutes < 60) {
return context.translate(
I18n.timeAgoMinutes,
args: {'count': '${difference.inMinutes}'},
);
}
if (difference.inHours < 24) {
return context.translate(
I18n.timeAgoHours,
args: {'count': '${difference.inHours}'},
);
}
if (difference.inDays < 30) {
return context.translate(
I18n.timeAgoDays,
args: {'count': '${difference.inDays}'},
);
}
return context.translate(
I18n.timeAgoMonths,
args: {'count': '${(difference.inDays / 30).floor()}'},
);
}
}

View File

@@ -1,224 +0,0 @@
import 'dart:async';
import 'package:settings/src/core/domain/entities/notification_entity.dart';
import 'package:settings/src/core/domain/repositories/notifications_repository.dart';
import 'package:settings/src/core/providers/notifications_repository_provider.dart';
import 'package:settings/src/features/notifications/presentation/providers/notifications_feed_provider.dart';
import 'package:settings/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 NotificationsRepository {}
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 = NotificationEntity(
id: '1',
deviceIdentificator: 'd1',
deviceName: 'Watch',
type: 'sos',
createdAt: 1,
);
final _alert2 = NotificationEntity(
id: '2',
deviceIdentificator: 'd1',
deviceName: 'Watch',
type: 'falldown',
createdAt: 2,
);
void main() {
ProviderContainer buildContainer({
required NotificationsRepository repo,
required FakeWebSocketService ws,
}) {
return makeContainer(
overrides: [
notificationsRepositoryProvider.overrideWithValue(repo),
webSocketServiceProvider.overrideWithValue(ws),
],
);
}
group('NotificationsFeed.build', () {
test('loads first page with no filter', () async {
final repo = MockAlertsRepository();
final ws = FakeWebSocketService();
when(
() => repo.getNotifications(
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.getNotifications(page: 1, pageSize: 20, type: null))
.called(1);
});
test('refetches when filter changes', () async {
final repo = MockAlertsRepository();
final ws = FakeWebSocketService();
when(
() => repo.getNotifications(
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.getNotifications(page: 1, pageSize: 20, type: 'sos'))
.called(1);
});
});
group('NotificationsFeed.loadMore', () {
test('appends next page', () async {
final repo = MockAlertsRepository();
final ws = FakeWebSocketService();
when(
() => repo.getNotifications(
page: 1,
pageSize: any(named: 'pageSize'),
type: any(named: 'type'),
),
).thenAnswer((_) async => ([_alert1], 2));
when(
() => repo.getNotifications(
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.getNotifications(
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.getNotifications(page: 1, pageSize: 20, type: null))
.called(1);
verifyNever(() => repo.getNotifications(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.getNotifications(
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.getNotifications(
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

@@ -55,6 +55,8 @@ class AppRoutes {
static const controlPanel = '$legacyDashboard/control_panel';
static const customerService = '$controlPanel/customer_service';
static const deviceNotifications = '$controlPanel/notifications';
static String deviceNotificationsByType(String type) =>
'$deviceNotifications/$type';
static const deviceManagement = '$legacyDashboard/device_management';
static const legacyLocation = '$legacyDashboard/location';

View File

@@ -902,6 +902,11 @@
"alarm": "Alarme",
"alertsTitle": "Alarme",
"alertsEmpty": "Keine Alarme",
"timeAgoNow": "jetzt",
"timeAgoMinutes": "{count} Min.",
"timeAgoHours": "{count} Std.",
"timeAgoDays": "{count} T.",
"timeAgoMonths": "{count} Mon.",
"alertsFilterAll": "Alle",
"alertsLoadError": "Fehler beim Laden der Alarme",
"alertTypeSos": "SOS",

View File

@@ -682,6 +682,11 @@
"alarm": "Alarms",
"alertsTitle": "Alerts",
"alertsEmpty": "No alerts",
"timeAgoNow": "now",
"timeAgoMinutes": "{count} min",
"timeAgoHours": "{count}h",
"timeAgoDays": "{count}d",
"timeAgoMonths": "{count}mo",
"alertsFilterAll": "All",
"alertsLoadError": "Error loading alerts",
"alertTypeSos": "SOS",

View File

@@ -683,6 +683,11 @@
"alarm": "Alarmas",
"alertsTitle": "Alertas",
"alertsEmpty": "No hay alertas",
"timeAgoNow": "ahora",
"timeAgoMinutes": "{count} min",
"timeAgoHours": "{count}h",
"timeAgoDays": "{count}d",
"timeAgoMonths": "{count}m",
"alertsFilterAll": "Todas",
"alertsLoadError": "Error al cargar alertas",
"alertTypeSos": "SOS",

View File

@@ -902,6 +902,11 @@
"alarm": "Alarmes",
"alertsTitle": "Alertes",
"alertsEmpty": "Aucune alerte",
"timeAgoNow": "maintenant",
"timeAgoMinutes": "{count} min",
"timeAgoHours": "{count}h",
"timeAgoDays": "{count}j",
"timeAgoMonths": "{count}m",
"alertsFilterAll": "Toutes",
"alertsLoadError": "Erreur lors du chargement des alertes",
"alertTypeSos": "SOS",

View File

@@ -902,6 +902,11 @@
"alarm": "Sveglie",
"alertsTitle": "Avvisi",
"alertsEmpty": "Nessun avviso",
"timeAgoNow": "ora",
"timeAgoMinutes": "{count} min",
"timeAgoHours": "{count}h",
"timeAgoDays": "{count}g",
"timeAgoMonths": "{count}m",
"alertsFilterAll": "Tutti",
"alertsLoadError": "Errore nel caricamento degli avvisi",
"alertTypeSos": "SOS",

View File

@@ -902,6 +902,11 @@
"alarm": "Alarmes",
"alertsTitle": "Alertas",
"alertsEmpty": "Sem alertas",
"timeAgoNow": "agora",
"timeAgoMinutes": "{count} min",
"timeAgoHours": "{count}h",
"timeAgoDays": "{count}d",
"timeAgoMonths": "{count}m",
"alertsFilterAll": "Todos",
"alertsLoadError": "Erro ao carregar alertas",
"alertTypeSos": "SOS",

View File

@@ -84,6 +84,11 @@ class I18n {
static const String alerts = 'alerts';
static const String alertsDescription = 'alertsDescription';
static const String alertsEmpty = 'alertsEmpty';
static const String timeAgoNow = 'timeAgoNow';
static const String timeAgoMinutes = 'timeAgoMinutes';
static const String timeAgoHours = 'timeAgoHours';
static const String timeAgoDays = 'timeAgoDays';
static const String timeAgoMonths = 'timeAgoMonths';
static const String alertsFilterAll = 'alertsFilterAll';
static const String alertsLoadError = 'alertsLoadError';
static const String alertsTitle = 'alertsTitle';