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.
@@ -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 {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -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;
@@ -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
- Future.delayed(const Duration(seconds: 3), () {
51
- if (!ref.context.mounted) return;
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) => KasyChromeOrbIconButton(
66
- icon: KasyIcons.moreVert,
67
- iconSize: 18,
68
- foregroundColor: ctx.colors.onSurface,
69
- onPressed: () => showNotificationSettingsSheet(ctx),
70
- tooltip: 'Opções',
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
- NotificationTileComponent(
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
- group('User is connected', () {
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
- 'Load notifications => should show 20 notifications',
38
+ 'after 3s on screen, all notifications are marked as read',
14
39
  (tester) async {
15
40
  await tester.pumpPage(
16
- userState: 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
- await tester.pump(const Duration(seconds: 3));
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
- expect(find.byType(NotificationsPage), findsOneWidget);
30
- expect(find.text("Notifications"), findsOneWidget);
31
- expect(find.byType(NotificationTile), findsAtLeastNWidgets(3));
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
- 'Load notifications, wait 3 seconds => all notifications are now read',
67
+ 'delete all button shows, opens confirm dialog, and clears the list',
37
68
  (tester) async {
38
69
  await tester.pumpPage(
39
- userState: 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
- var firstNotification = tester.firstWidget<NotificationTileComponent>(
51
- find.byType(NotificationTileComponent),
52
- );
53
- expect(firstNotification.notification.readAt, isNull);
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.pump(const Duration(seconds: 3));
79
+ await tester.tap(deleteAllButton);
56
80
  await tester.pumpAndSettle();
57
- firstNotification = tester.firstWidget<NotificationTileComponent>(
58
- find.byType(NotificationTileComponent),
59
- );
60
81
 
61
- expect(
62
- firstNotification.notification.readAt,
63
- isNotNull,
64
- reason: "All notifications should be read after 3 seconds",
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
  });