feat(notifications): category list with tap-to-filter navigation

This commit is contained in:
2026-04-22 20:29:49 +02:00
parent 6ff11b8c1e
commit 1961be3805

View File

@@ -5,6 +5,7 @@ import 'package:control_panel/src/features/notifications/presentation/widgets/no
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_theme/legacy_theme.dart'; import 'package:legacy_theme/legacy_theme.dart';
import 'package:legacy_ui/legacy_ui.dart';
import 'package:navigation/navigation.dart'; import 'package:navigation/navigation.dart';
import 'package:sf_localizations/sf_localizations.dart'; import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart'; import 'package:sf_shared/sf_shared.dart';
@@ -19,13 +20,14 @@ class NotificationsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final primaryColor = context.sfColors.legacyPrimary; final primaryColor = context.sfColors.legacyPrimary;
final selectedType = ref.watch(notificationsFilterProvider); final selectedType = ref.watch(notificationsFilterProvider);
final feedAsync = ref.watch(notificationsFeedProvider);
ref.listen(notificationsFeedProvider, (prev, next) async { if (selectedType != null) {
if (next.hasError && prev?.hasError != true) { return _FilteredNotificationsScreen(
await showErrorDialog(context, I18n.alertsLoadError); type: selectedType,
} onBack: () =>
}); ref.read(notificationsFilterProvider.notifier).select(null),
);
}
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
@@ -53,81 +55,176 @@ class NotificationsScreen extends ConsumerWidget {
), ),
), ),
), ),
body: Column( body: SingleChildScrollView(
children: [ padding: EdgeInsets.symmetric(
_FilterChips( horizontal: SizeUtils.getByScreen(small: 14, big: 12),
selectedType: selectedType, vertical: SizeUtils.getByScreen(small: 12, big: 10),
onSelected: ref.read(notificationsFilterProvider.notifier).select, ),
), child: Column(
Expanded( children: _notificationTypes.map((entry) {
child: feedAsync.when( return Padding(
loading: () => const Center(child: CircularProgressIndicator()), padding: EdgeInsets.only(
error: (_, __) => Center( bottom: SizeUtils.getByScreen(small: 8, big: 7),
child: Text(context.translate(I18n.alertsLoadError)),
), ),
data: (feed) => _NotificationsList( child: SectionButton(
notifications: feed.notifications, onPressed: () => ref
isLoadingMore: feed.isLoadingMore, .read(notificationsFilterProvider.notifier)
.select(entry.type),
icon: Icon(
entry.icon,
size: SizeUtils.getByScreen(small: 34, big: 38),
color: primaryColor,
),
iconPadding: SizeUtils.getByScreen(small: 14, big: 12),
body: Text(
context.translate(entry.labelKey),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 16, big: 17),
fontWeight: FontWeight.w500,
),
),
), ),
), );
), }).toList(),
], ),
), ),
); );
} }
} }
class _FilterChips extends StatelessWidget { class _NotificationType {
final String? selectedType; final String type;
final ValueChanged<String?> onSelected; final String labelKey;
final IconData icon;
const _FilterChips({required this.selectedType, required this.onSelected}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final primaryColor = context.sfColors.legacyPrimary; final primaryColor = context.sfColors.legacyPrimary;
final feedAsync = ref.watch(notificationsFeedProvider);
final filters = [ final typeEntry = _notificationTypes
(null, context.translate(I18n.alertsFilterAll)), .where((e) => e.type == type)
('sos', 'SOS'), .firstOrNull;
('falldown', context.translate(I18n.alertTypeFalldown)), final title = typeEntry != null
('lowBattery', context.translate(I18n.alertTypeLowBattery)), ? context.translate(typeEntry.labelKey)
('disconnect', context.translate(I18n.alertTypeDisconnect)), : type;
('geofenceIn', context.translate(I18n.alertTypeGeofenceIn)),
('geofenceOut', context.translate(I18n.alertTypeGeofenceOut)),
];
return SizedBox( ref.listen(notificationsFeedProvider, (prev, next) async {
height: SizeUtils.getByScreen<double>(small: 44, big: 40), if (next.hasError && prev?.hasError != true) {
child: ListView.separated( await showErrorDialog(context, I18n.alertsLoadError);
scrollDirection: Axis.horizontal, }
padding: EdgeInsets.symmetric( });
horizontal: SizeUtils.getByScreen(small: 16, big: 14),
vertical: SizeUtils.getByScreen(small: 4, big: 3),
),
itemCount: filters.length,
separatorBuilder: (_, __) => SizedBox(
width: SizeUtils.getByScreen(small: 6, big: 5),
),
itemBuilder: (context, index) {
final (type, label) = filters[index];
final isSelected = type == selectedType;
return ChoiceChip( return PopScope(
label: Text(label), canPop: false,
selected: isSelected, onPopInvokedWithResult: (didPop, _) {
selectedColor: primaryColor, if (!didPop) onBack();
labelStyle: TextStyle( },
fontSize: SizeUtils.getByScreen(small: 12, big: 11), child: Scaffold(
fontWeight: FontWeight.w600, backgroundColor: Theme.of(context).colorScheme.surface,
color: isSelected ? Colors.white : primaryColor, 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),
), ),
side: BorderSide(color: primaryColor), ),
onSelected: (_) => onSelected(type), title: Text(
visualDensity: VisualDensity.compact, title.toUpperCase(),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, style: TextStyle(
); fontSize: SizeUtils.getByScreen(small: 20, big: 19),
}, fontWeight: FontWeight.w500,
letterSpacing: 0,
color: primaryColor,
),
),
),
body: feedAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => Center(
child: Text(context.translate(I18n.alertsLoadError)),
),
data: (feed) => _NotificationsList(
notifications: feed.notifications,
isLoadingMore: feed.isLoadingMore,
),
),
), ),
); );
} }