kasy-cli 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/scaffold/backends/api/patch/lib/features/notifications/api/notifications_api.dart +19 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/notifications_api.dart +19 -0
- package/package.json +1 -1
- package/templates/firebase/lib/features/notifications/api/notifications_api.dart +14 -0
- package/templates/firebase/lib/features/notifications/providers/notifications_provider.dart +41 -0
- package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +11 -0
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +110 -9
- package/templates/firebase/lib/i18n/en.i18n.json +8 -1
- package/templates/firebase/lib/i18n/es.i18n.json +8 -1
- package/templates/firebase/lib/i18n/pt.i18n.json +8 -1
- package/templates/firebase/test/features/notifications/data/notifications_api_fake.dart +5 -0
- package/templates/firebase/test/features/notifications/ui/notifications_page_test.dart +113 -37
package/lib/scaffold/backends/api/patch/lib/features/notifications/api/notifications_api.dart
CHANGED
|
@@ -52,6 +52,9 @@ abstract class NotificationsApi {
|
|
|
52
52
|
// Used to mark a notification as read
|
|
53
53
|
Future<void> read(String userId, String notificationId);
|
|
54
54
|
|
|
55
|
+
// Used to delete a single notification (per-user only)
|
|
56
|
+
Future<void> delete(String userId, String notificationId);
|
|
57
|
+
|
|
55
58
|
// Used to get the unread notifications count
|
|
56
59
|
Stream<int> unreadNotifications(String userId);
|
|
57
60
|
|
|
@@ -196,6 +199,22 @@ class FirebaseNotificationsApi implements NotificationsApi {
|
|
|
196
199
|
}
|
|
197
200
|
}
|
|
198
201
|
|
|
202
|
+
@override
|
|
203
|
+
Future<void> delete(String userId, String notificationId) async {
|
|
204
|
+
try {
|
|
205
|
+
final _ = await _client.delete(
|
|
206
|
+
'/users/$userId/notifications/$notificationId',
|
|
207
|
+
);
|
|
208
|
+
} on DioException catch (e) {
|
|
209
|
+
throw ApiError.fromDioException(e);
|
|
210
|
+
} catch (e, stacktrace) {
|
|
211
|
+
throw ApiError(
|
|
212
|
+
code: 0,
|
|
213
|
+
message: '$e: $stacktrace',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
199
218
|
@override
|
|
200
219
|
Stream<int> unreadNotifications(String userId) async* {
|
|
201
220
|
try {
|
package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/notifications_api.dart
CHANGED
|
@@ -49,6 +49,9 @@ abstract class NotificationsApi {
|
|
|
49
49
|
// Used to mark a notification as read
|
|
50
50
|
Future<void> read(String userId, String notificationId);
|
|
51
51
|
|
|
52
|
+
// Used to delete a single notification (per-user only)
|
|
53
|
+
Future<void> delete(String userId, String notificationId);
|
|
54
|
+
|
|
52
55
|
// Used to get the unread notifications count
|
|
53
56
|
Stream<int> unreadNotifications(String userId);
|
|
54
57
|
|
|
@@ -197,6 +200,22 @@ class FirebaseNotificationsApi implements NotificationsApi {
|
|
|
197
200
|
}
|
|
198
201
|
}
|
|
199
202
|
|
|
203
|
+
@override
|
|
204
|
+
Future<void> delete(String userId, String notificationId) async {
|
|
205
|
+
try {
|
|
206
|
+
await _client
|
|
207
|
+
.from('notifications')
|
|
208
|
+
.delete()
|
|
209
|
+
.eq('user_id', userId)
|
|
210
|
+
.eq('id', notificationId);
|
|
211
|
+
} catch (e, stacktrace) {
|
|
212
|
+
throw ApiError(
|
|
213
|
+
code: 0,
|
|
214
|
+
message: '$e: $stacktrace',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
200
219
|
@override
|
|
201
220
|
Stream<int> unreadNotifications(String userId) {
|
|
202
221
|
try {
|
package/package.json
CHANGED
|
@@ -46,6 +46,8 @@ abstract class NotificationsApi {
|
|
|
46
46
|
|
|
47
47
|
// Used to mark a notification as read
|
|
48
48
|
Future<void> read(String userId, String notificationId);
|
|
49
|
+
// Used to delete a single notification (per-user only)
|
|
50
|
+
Future<void> delete(String userId, String notificationId);
|
|
49
51
|
// Used to get the unread notifications count
|
|
50
52
|
Stream<int> unreadNotifications(String userId);
|
|
51
53
|
|
|
@@ -196,6 +198,18 @@ class FirebaseNotificationsApi implements NotificationsApi {
|
|
|
196
198
|
}
|
|
197
199
|
}
|
|
198
200
|
|
|
201
|
+
@override
|
|
202
|
+
Future<void> delete(String userId, String notificationId) async {
|
|
203
|
+
try {
|
|
204
|
+
await _collection(userId).doc(notificationId).delete();
|
|
205
|
+
} catch (e, stacktrace) {
|
|
206
|
+
throw ApiError(
|
|
207
|
+
code: 0,
|
|
208
|
+
message: '$e: $stacktrace',
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
199
213
|
@override
|
|
200
214
|
Stream<int> unreadNotifications(String userId) async* {
|
|
201
215
|
// count() aggregation: 1 read per 1000 docs counted (vs N reads for full snapshot).
|
|
@@ -67,6 +67,47 @@ class NotificationsNotifier extends _$NotificationsNotifier {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/// Delete a single notification. Optimistically removes from state, then
|
|
71
|
+
/// persists the deletion on the server. Reverts on failure.
|
|
72
|
+
Future<void> delete(Notification notification) async {
|
|
73
|
+
if (!state.hasValue) return;
|
|
74
|
+
final notificationRepository = ref.read(notificationRepositoryProvider);
|
|
75
|
+
final userId = ref.read(userStateNotifierProvider).user.idOrNull;
|
|
76
|
+
if (userId == null) return;
|
|
77
|
+
final previous = state.value!;
|
|
78
|
+
state = AsyncValue.data(
|
|
79
|
+
previous.copyWith(
|
|
80
|
+
data: previous.data.where((n) => n.id != notification.id).toList(),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
try {
|
|
84
|
+
await notificationRepository.delete(userId, notification);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
Logger().e("delete error $e");
|
|
87
|
+
state = AsyncValue.data(previous);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Delete every notification currently loaded. Optimistically clears state,
|
|
92
|
+
/// then persists deletions on the server. Reverts on failure.
|
|
93
|
+
Future<void> deleteAll() async {
|
|
94
|
+
if (!state.hasValue) return;
|
|
95
|
+
final notificationRepository = ref.read(notificationRepositoryProvider);
|
|
96
|
+
final userId = ref.read(userStateNotifierProvider).user.idOrNull;
|
|
97
|
+
if (userId == null) return;
|
|
98
|
+
final previous = state.value!;
|
|
99
|
+
if (previous.data.isEmpty) return;
|
|
100
|
+
state = AsyncValue.data(previous.copyWith(data: const []));
|
|
101
|
+
try {
|
|
102
|
+
await Future.wait(
|
|
103
|
+
previous.data.map((n) => notificationRepository.delete(userId, n)),
|
|
104
|
+
);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
Logger().e("deleteAll error $e");
|
|
107
|
+
state = AsyncValue.data(previous);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
70
111
|
Future<void> refresh() async {
|
|
71
112
|
if (_locked) return;
|
|
72
113
|
_locked = true;
|
package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart
CHANGED
|
@@ -25,6 +25,9 @@ abstract class NotificationsRepository implements OnStartService {
|
|
|
25
25
|
// mark a notification as read
|
|
26
26
|
Future<Notification> read(String userId, Notification notification);
|
|
27
27
|
|
|
28
|
+
// delete a single notification (per-user only)
|
|
29
|
+
Future<void> delete(String userId, Notification notification);
|
|
30
|
+
|
|
28
31
|
// listen to the unread notifications count
|
|
29
32
|
Stream<int> listenToUnreadNotificationsCount(String userId);
|
|
30
33
|
|
|
@@ -207,6 +210,14 @@ class AppNotificationsRepository implements NotificationsRepository {
|
|
|
207
210
|
return notification.copyWith(readAt: DateTime.now());
|
|
208
211
|
}
|
|
209
212
|
|
|
213
|
+
@override
|
|
214
|
+
Future<void> delete(String userId, Notification notification) async {
|
|
215
|
+
if (notification.id == null) {
|
|
216
|
+
throw Exception('A notification without id cannot be deleted');
|
|
217
|
+
}
|
|
218
|
+
await _notificationsApi.delete(userId, notification.id!);
|
|
219
|
+
}
|
|
220
|
+
|
|
210
221
|
@override
|
|
211
222
|
Stream<int> listenToUnreadNotificationsCount(String userId) {
|
|
212
223
|
return _notificationsApi.unreadNotifications(userId);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import 'dart:async';
|
|
2
|
+
|
|
1
3
|
import 'package:flutter/material.dart';
|
|
2
4
|
import 'package:flutter/rendering.dart';
|
|
3
5
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
@@ -5,6 +7,7 @@ import 'package:kasy_kit/components/kasy_app_bar.dart';
|
|
|
5
7
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
6
8
|
import 'package:kasy_kit/features/notifications/providers/models/notification.dart'
|
|
7
9
|
as app;
|
|
10
|
+
import 'package:kasy_kit/features/notifications/providers/models/notification_list.dart';
|
|
8
11
|
import 'package:kasy_kit/features/notifications/providers/notifications_provider.dart';
|
|
9
12
|
import 'package:kasy_kit/features/notifications/ui/components/notification_settings_sheet.dart';
|
|
10
13
|
import 'package:kasy_kit/features/notifications/ui/components/notification_tile.dart';
|
|
@@ -19,6 +22,7 @@ class NotificationsPage extends ConsumerStatefulWidget {
|
|
|
19
22
|
|
|
20
23
|
class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
|
21
24
|
final ScrollController _scrollController = ScrollController();
|
|
25
|
+
Timer? _autoReadTimer;
|
|
22
26
|
|
|
23
27
|
@override
|
|
24
28
|
void initState() {
|
|
@@ -29,6 +33,7 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
|
|
29
33
|
|
|
30
34
|
@override
|
|
31
35
|
void dispose() {
|
|
36
|
+
_autoReadTimer?.cancel();
|
|
32
37
|
_scrollController.removeListener(_onScrollChange);
|
|
33
38
|
_scrollController.dispose();
|
|
34
39
|
super.dispose();
|
|
@@ -47,27 +52,86 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
void requestReadAll() {
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
_autoReadTimer?.cancel();
|
|
56
|
+
_autoReadTimer = Timer(const Duration(seconds: 3), () {
|
|
57
|
+
if (!mounted) return;
|
|
52
58
|
ref.read(notificationsProvider.notifier).readAll();
|
|
53
59
|
});
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
bool _hasAny(AsyncValue<NotificationsList> state) {
|
|
63
|
+
if (!state.hasValue) return false;
|
|
64
|
+
return state.value!.data.isNotEmpty;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
Future<void> _confirmAndDeleteAll(BuildContext context) async {
|
|
68
|
+
final tr = t.notifications;
|
|
69
|
+
final confirmed = await showDialog<bool>(
|
|
70
|
+
context: context,
|
|
71
|
+
builder: (dialogCtx) => AlertDialog(
|
|
72
|
+
title: Text(tr.delete_all_confirm_title),
|
|
73
|
+
content: Text(tr.delete_all_confirm_message),
|
|
74
|
+
actions: [
|
|
75
|
+
TextButton(
|
|
76
|
+
onPressed: () => Navigator.of(dialogCtx).pop(false),
|
|
77
|
+
child: Text(tr.cancel_action),
|
|
78
|
+
),
|
|
79
|
+
TextButton(
|
|
80
|
+
onPressed: () => Navigator.of(dialogCtx).pop(true),
|
|
81
|
+
style: TextButton.styleFrom(foregroundColor: context.colors.error),
|
|
82
|
+
child: Text(tr.delete_action),
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
if (confirmed != true || !context.mounted) return;
|
|
88
|
+
await ref.read(notificationsProvider.notifier).deleteAll();
|
|
89
|
+
if (!context.mounted) return;
|
|
90
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
91
|
+
SnackBar(content: Text(tr.deleted_all)),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
Future<void> _deleteOne(BuildContext context, app.Notification n) async {
|
|
96
|
+
final tr = t.notifications;
|
|
97
|
+
await ref.read(notificationsProvider.notifier).delete(n);
|
|
98
|
+
if (!context.mounted) return;
|
|
99
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
100
|
+
SnackBar(content: Text(tr.deleted_one)),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
@override
|
|
57
105
|
Widget build(BuildContext context) {
|
|
58
106
|
final notificationsState = ref.watch(notificationsProvider);
|
|
107
|
+
final hasAny = _hasAny(notificationsState);
|
|
59
108
|
|
|
60
109
|
return KasyOverlayScaffold(
|
|
61
110
|
title: t.notifications.title,
|
|
62
111
|
appBarStyle: KasyAppBarStyle.rootTab,
|
|
63
112
|
scrollController: _scrollController,
|
|
64
113
|
trailing: Builder(
|
|
65
|
-
builder: (ctx) =>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
114
|
+
builder: (ctx) => Row(
|
|
115
|
+
mainAxisSize: MainAxisSize.min,
|
|
116
|
+
children: [
|
|
117
|
+
if (hasAny)
|
|
118
|
+
KasyChromeOrbIconButton(
|
|
119
|
+
key: const Key('delete_all_button'),
|
|
120
|
+
icon: KasyIcons.trash,
|
|
121
|
+
iconSize: 18,
|
|
122
|
+
foregroundColor: ctx.colors.onSurface,
|
|
123
|
+
onPressed: () => _confirmAndDeleteAll(ctx),
|
|
124
|
+
tooltip: t.notifications.delete_all,
|
|
125
|
+
),
|
|
126
|
+
if (hasAny) const SizedBox(width: KasySpacing.xs),
|
|
127
|
+
KasyChromeOrbIconButton(
|
|
128
|
+
icon: KasyIcons.moreVert,
|
|
129
|
+
iconSize: 18,
|
|
130
|
+
foregroundColor: ctx.colors.onSurface,
|
|
131
|
+
onPressed: () => showNotificationSettingsSheet(ctx),
|
|
132
|
+
tooltip: 'Opções',
|
|
133
|
+
),
|
|
134
|
+
],
|
|
71
135
|
),
|
|
72
136
|
),
|
|
73
137
|
onRefresh: () async {
|
|
@@ -94,12 +158,13 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
|
|
94
158
|
return switch (item) {
|
|
95
159
|
_HeaderItem(:final label) => _GroupLabel(label: label),
|
|
96
160
|
_TileItem(:final notification, :final animationIndex) =>
|
|
97
|
-
|
|
161
|
+
_SwipeToDeleteTile(
|
|
98
162
|
notification: notification,
|
|
99
163
|
index: animationIndex,
|
|
100
164
|
onTap: (n) => ref
|
|
101
165
|
.read(notificationsProvider.notifier)
|
|
102
166
|
.onTapNotification(n),
|
|
167
|
+
onDismissed: (n) => _deleteOne(context, n),
|
|
103
168
|
),
|
|
104
169
|
};
|
|
105
170
|
},
|
|
@@ -200,3 +265,39 @@ class _GroupLabel extends StatelessWidget {
|
|
|
200
265
|
);
|
|
201
266
|
}
|
|
202
267
|
}
|
|
268
|
+
|
|
269
|
+
/// Wraps [NotificationTileComponent] in a [Dismissible] so the user can
|
|
270
|
+
/// swipe a notification to the left to delete it.
|
|
271
|
+
class _SwipeToDeleteTile extends StatelessWidget {
|
|
272
|
+
final app.Notification notification;
|
|
273
|
+
final int index;
|
|
274
|
+
final void Function(app.Notification) onTap;
|
|
275
|
+
final void Function(app.Notification) onDismissed;
|
|
276
|
+
|
|
277
|
+
const _SwipeToDeleteTile({
|
|
278
|
+
required this.notification,
|
|
279
|
+
required this.index,
|
|
280
|
+
required this.onTap,
|
|
281
|
+
required this.onDismissed,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
@override
|
|
285
|
+
Widget build(BuildContext context) {
|
|
286
|
+
return Dismissible(
|
|
287
|
+
key: ValueKey('notification_${notification.id ?? index}'),
|
|
288
|
+
direction: DismissDirection.endToStart,
|
|
289
|
+
background: Container(
|
|
290
|
+
alignment: Alignment.centerRight,
|
|
291
|
+
padding: const EdgeInsets.symmetric(horizontal: KasySpacing.lg),
|
|
292
|
+
color: context.colors.error,
|
|
293
|
+
child: Icon(KasyIcons.trash, color: context.colors.onError),
|
|
294
|
+
),
|
|
295
|
+
onDismissed: (_) => onDismissed(notification),
|
|
296
|
+
child: NotificationTileComponent(
|
|
297
|
+
notification: notification,
|
|
298
|
+
index: index,
|
|
299
|
+
onTap: onTap,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -438,7 +438,14 @@
|
|
|
438
438
|
"group_today": "Today",
|
|
439
439
|
"group_yesterday": "Yesterday",
|
|
440
440
|
"group_older": "Older",
|
|
441
|
-
"empty_cta": "Enable notifications"
|
|
441
|
+
"empty_cta": "Enable notifications",
|
|
442
|
+
"delete_all": "Delete all",
|
|
443
|
+
"delete_all_confirm_title": "Delete all notifications?",
|
|
444
|
+
"delete_all_confirm_message": "This will permanently remove every notification from your account. This cannot be undone.",
|
|
445
|
+
"delete_action": "Delete",
|
|
446
|
+
"cancel_action": "Cancel",
|
|
447
|
+
"deleted_one": "Notification deleted",
|
|
448
|
+
"deleted_all": "All notifications deleted"
|
|
442
449
|
},
|
|
443
450
|
"bottom_router": {
|
|
444
451
|
"fake_page_text": "This is a fake page"
|
|
@@ -438,7 +438,14 @@
|
|
|
438
438
|
"group_today": "Hoy",
|
|
439
439
|
"group_yesterday": "Ayer",
|
|
440
440
|
"group_older": "Más antiguas",
|
|
441
|
-
"empty_cta": "Activar notificaciones"
|
|
441
|
+
"empty_cta": "Activar notificaciones",
|
|
442
|
+
"delete_all": "Eliminar todo",
|
|
443
|
+
"delete_all_confirm_title": "¿Eliminar todas las notificaciones?",
|
|
444
|
+
"delete_all_confirm_message": "Esto eliminará todas las notificaciones de tu cuenta. Esta acción no se puede deshacer.",
|
|
445
|
+
"delete_action": "Eliminar",
|
|
446
|
+
"cancel_action": "Cancelar",
|
|
447
|
+
"deleted_one": "Notificación eliminada",
|
|
448
|
+
"deleted_all": "Todas las notificaciones eliminadas"
|
|
442
449
|
},
|
|
443
450
|
"bottom_router": {
|
|
444
451
|
"fake_page_text": "Esta es una página de prueba"
|
|
@@ -438,7 +438,14 @@
|
|
|
438
438
|
"group_today": "Hoje",
|
|
439
439
|
"group_yesterday": "Ontem",
|
|
440
440
|
"group_older": "Mais antigas",
|
|
441
|
-
"empty_cta": "Ativar notificações"
|
|
441
|
+
"empty_cta": "Ativar notificações",
|
|
442
|
+
"delete_all": "Excluir tudo",
|
|
443
|
+
"delete_all_confirm_title": "Excluir todas as notificações?",
|
|
444
|
+
"delete_all_confirm_message": "Isso vai remover todas as notificações da sua conta. Essa ação não pode ser desfeita.",
|
|
445
|
+
"delete_action": "Excluir",
|
|
446
|
+
"cancel_action": "Cancelar",
|
|
447
|
+
"deleted_one": "Notificação excluída",
|
|
448
|
+
"deleted_all": "Todas as notificações foram excluídas"
|
|
442
449
|
},
|
|
443
450
|
"bottom_router": {
|
|
444
451
|
"fake_page_text": "Esta é uma página de exemplo"
|
|
@@ -44,6 +44,11 @@ class FakeNotificationsApi implements NotificationsApi {
|
|
|
44
44
|
return Future.value();
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
@override
|
|
48
|
+
Future<void> delete(String userId, String notificationId) {
|
|
49
|
+
return Future.value();
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
@override
|
|
48
53
|
Stream<int> unreadNotifications(String userId) {
|
|
49
54
|
return Stream.value(1);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import 'package:flutter/material.dart';
|
|
1
2
|
import 'package:flutter_test/flutter_test.dart';
|
|
2
3
|
import 'package:kasy_kit/core/data/models/user.dart';
|
|
3
4
|
import 'package:kasy_kit/core/states/models/user_state.dart';
|
|
@@ -8,61 +9,136 @@ import 'package:kasy_kit/features/notifications/ui/widgets/notification_tile.dar
|
|
|
8
9
|
import '../../../test_utils.dart';
|
|
9
10
|
|
|
10
11
|
void main() {
|
|
11
|
-
|
|
12
|
+
UserState authenticatedUser() => UserState(
|
|
13
|
+
user: User.authenticated(
|
|
14
|
+
id: '1',
|
|
15
|
+
email: 'user@email.com',
|
|
16
|
+
name: 'user name',
|
|
17
|
+
onboarded: true,
|
|
18
|
+
creationDate: DateTime.now().subtract(const Duration(days: 4)),
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
group('NotificationsPage — rendering', () {
|
|
23
|
+
testWidgets('renders title and notification tiles', (tester) async {
|
|
24
|
+
await tester.pumpPage(
|
|
25
|
+
userState: authenticatedUser(),
|
|
26
|
+
home: NotificationsPage(),
|
|
27
|
+
);
|
|
28
|
+
await tester.pumpAndSettle();
|
|
29
|
+
|
|
30
|
+
expect(find.byType(NotificationsPage), findsOneWidget);
|
|
31
|
+
expect(find.text('Notifications'), findsOneWidget);
|
|
32
|
+
expect(find.byType(NotificationTile), findsAtLeastNWidgets(3));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
group('NotificationsPage — auto-read behavior', () {
|
|
12
37
|
testWidgets(
|
|
13
|
-
'
|
|
38
|
+
'after 3s on screen, all notifications are marked as read',
|
|
14
39
|
(tester) async {
|
|
15
40
|
await tester.pumpPage(
|
|
16
|
-
userState:
|
|
17
|
-
user: User.authenticated(
|
|
18
|
-
id: '1',
|
|
19
|
-
email: 'user@email.com',
|
|
20
|
-
name: 'user name',
|
|
21
|
-
onboarded: true,
|
|
22
|
-
creationDate: DateTime.now().subtract(const Duration(days: 4)),
|
|
23
|
-
),
|
|
24
|
-
),
|
|
41
|
+
userState: authenticatedUser(),
|
|
25
42
|
home: NotificationsPage(),
|
|
26
43
|
);
|
|
27
|
-
|
|
44
|
+
// Auto-read fires at 3000ms — give it a little headroom.
|
|
45
|
+
await tester.pump(const Duration(milliseconds: 3100));
|
|
28
46
|
await tester.pumpAndSettle();
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
|
|
48
|
+
final tiles = tester
|
|
49
|
+
.widgetList<NotificationTileComponent>(
|
|
50
|
+
find.byType(NotificationTileComponent),
|
|
51
|
+
)
|
|
52
|
+
.toList();
|
|
53
|
+
expect(tiles, isNotEmpty);
|
|
54
|
+
for (final tile in tiles) {
|
|
55
|
+
expect(
|
|
56
|
+
tile.notification.readAt,
|
|
57
|
+
isNotNull,
|
|
58
|
+
reason: 'All notifications should be read after auto-read fires',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
32
61
|
},
|
|
33
62
|
);
|
|
63
|
+
});
|
|
34
64
|
|
|
65
|
+
group('NotificationsPage — delete all', () {
|
|
35
66
|
testWidgets(
|
|
36
|
-
'
|
|
67
|
+
'delete all button shows, opens confirm dialog, and clears the list',
|
|
37
68
|
(tester) async {
|
|
38
69
|
await tester.pumpPage(
|
|
39
|
-
userState:
|
|
40
|
-
user: User.authenticated(
|
|
41
|
-
id: '1',
|
|
42
|
-
email: 'user@email.com',
|
|
43
|
-
name: 'user name',
|
|
44
|
-
onboarded: true,
|
|
45
|
-
creationDate: DateTime.now().subtract(const Duration(days: 4)),
|
|
46
|
-
),
|
|
47
|
-
),
|
|
70
|
+
userState: authenticatedUser(),
|
|
48
71
|
home: NotificationsPage(),
|
|
49
72
|
);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
await tester.pumpAndSettle();
|
|
74
|
+
|
|
75
|
+
// Button is visible when there are notifications.
|
|
76
|
+
final deleteAllButton = find.byKey(const Key('delete_all_button'));
|
|
77
|
+
expect(deleteAllButton, findsOneWidget);
|
|
54
78
|
|
|
55
|
-
await tester.
|
|
79
|
+
await tester.tap(deleteAllButton);
|
|
56
80
|
await tester.pumpAndSettle();
|
|
57
|
-
firstNotification = tester.firstWidget<NotificationTileComponent>(
|
|
58
|
-
find.byType(NotificationTileComponent),
|
|
59
|
-
);
|
|
60
81
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
82
|
+
// Confirm dialog appears with destructive action.
|
|
83
|
+
expect(find.text('Delete all notifications?'), findsOneWidget);
|
|
84
|
+
expect(find.text('Delete'), findsOneWidget);
|
|
85
|
+
expect(find.text('Cancel'), findsOneWidget);
|
|
86
|
+
|
|
87
|
+
await tester.tap(find.text('Delete'));
|
|
88
|
+
await tester.pumpAndSettle();
|
|
89
|
+
|
|
90
|
+
// After deletion, no notification tiles remain.
|
|
91
|
+
expect(find.byType(NotificationTileComponent), findsNothing);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
testWidgets('cancel on confirm dialog keeps notifications intact',
|
|
96
|
+
(tester) async {
|
|
97
|
+
await tester.pumpPage(
|
|
98
|
+
userState: authenticatedUser(),
|
|
99
|
+
home: NotificationsPage(),
|
|
100
|
+
);
|
|
101
|
+
await tester.pumpAndSettle();
|
|
102
|
+
|
|
103
|
+
final countBefore = tester
|
|
104
|
+
.widgetList<NotificationTileComponent>(
|
|
105
|
+
find.byType(NotificationTileComponent),
|
|
106
|
+
)
|
|
107
|
+
.length;
|
|
108
|
+
expect(countBefore, greaterThan(0));
|
|
109
|
+
|
|
110
|
+
await tester.tap(find.byKey(const Key('delete_all_button')));
|
|
111
|
+
await tester.pumpAndSettle();
|
|
112
|
+
await tester.tap(find.text('Cancel'));
|
|
113
|
+
await tester.pumpAndSettle();
|
|
114
|
+
|
|
115
|
+
final countAfter = tester
|
|
116
|
+
.widgetList<NotificationTileComponent>(
|
|
117
|
+
find.byType(NotificationTileComponent),
|
|
118
|
+
)
|
|
119
|
+
.length;
|
|
120
|
+
expect(countAfter, countBefore);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
group('NotificationsPage — swipe to delete', () {
|
|
125
|
+
testWidgets(
|
|
126
|
+
'each tile is wrapped in a Dismissible configured for left-swipe',
|
|
127
|
+
(tester) async {
|
|
128
|
+
await tester.pumpPage(
|
|
129
|
+
userState: authenticatedUser(),
|
|
130
|
+
home: NotificationsPage(),
|
|
65
131
|
);
|
|
132
|
+
await tester.pumpAndSettle();
|
|
133
|
+
|
|
134
|
+
final dismissibles = tester
|
|
135
|
+
.widgetList<Dismissible>(find.byType(Dismissible))
|
|
136
|
+
.toList();
|
|
137
|
+
expect(dismissibles, isNotEmpty);
|
|
138
|
+
for (final d in dismissibles) {
|
|
139
|
+
expect(d.direction, DismissDirection.endToStart);
|
|
140
|
+
expect(d.background, isNotNull);
|
|
141
|
+
}
|
|
66
142
|
},
|
|
67
143
|
);
|
|
68
144
|
});
|