kasy-cli 1.7.1 → 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.1",
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);
@@ -53,21 +53,58 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
53
53
 
54
54
  void requestReadAll() {
55
55
  _autoReadTimer?.cancel();
56
- _autoReadTimer = Timer(const Duration(milliseconds: 1500), () {
56
+ _autoReadTimer = Timer(const Duration(seconds: 3), () {
57
57
  if (!mounted) return;
58
58
  ref.read(notificationsProvider.notifier).readAll();
59
59
  });
60
60
  }
61
61
 
62
- bool _hasUnread(AsyncValue<NotificationsList> state) {
62
+ bool _hasAny(AsyncValue<NotificationsList> state) {
63
63
  if (!state.hasValue) return false;
64
- return state.value!.data.any((n) => !n.seen);
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
+ );
65
102
  }
66
103
 
67
104
  @override
68
105
  Widget build(BuildContext context) {
69
106
  final notificationsState = ref.watch(notificationsProvider);
70
- final hasUnread = _hasUnread(notificationsState);
107
+ final hasAny = _hasAny(notificationsState);
71
108
 
72
109
  return KasyOverlayScaffold(
73
110
  title: t.notifications.title,
@@ -77,17 +114,16 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
77
114
  builder: (ctx) => Row(
78
115
  mainAxisSize: MainAxisSize.min,
79
116
  children: [
80
- if (hasUnread)
117
+ if (hasAny)
81
118
  KasyChromeOrbIconButton(
82
- key: const Key('mark_all_read_button'),
83
- icon: KasyIcons.check,
119
+ key: const Key('delete_all_button'),
120
+ icon: KasyIcons.trash,
84
121
  iconSize: 18,
85
122
  foregroundColor: ctx.colors.onSurface,
86
- onPressed: () =>
87
- ref.read(notificationsProvider.notifier).readAll(),
88
- tooltip: t.notifications.mark_all_read,
123
+ onPressed: () => _confirmAndDeleteAll(ctx),
124
+ tooltip: t.notifications.delete_all,
89
125
  ),
90
- if (hasUnread) const SizedBox(width: KasySpacing.xs),
126
+ if (hasAny) const SizedBox(width: KasySpacing.xs),
91
127
  KasyChromeOrbIconButton(
92
128
  icon: KasyIcons.moreVert,
93
129
  iconSize: 18,
@@ -122,12 +158,13 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
122
158
  return switch (item) {
123
159
  _HeaderItem(:final label) => _GroupLabel(label: label),
124
160
  _TileItem(:final notification, :final animationIndex) =>
125
- NotificationTileComponent(
161
+ _SwipeToDeleteTile(
126
162
  notification: notification,
127
163
  index: animationIndex,
128
164
  onTap: (n) => ref
129
165
  .read(notificationsProvider.notifier)
130
166
  .onTapNotification(n),
167
+ onDismissed: (n) => _deleteOne(context, n),
131
168
  ),
132
169
  };
133
170
  },
@@ -228,3 +265,39 @@ class _GroupLabel extends StatelessWidget {
228
265
  );
229
266
  }
230
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);
@@ -35,13 +35,14 @@ void main() {
35
35
 
36
36
  group('NotificationsPage — auto-read behavior', () {
37
37
  testWidgets(
38
- 'after 1.5s on screen, all notifications are marked as read',
38
+ 'after 3s on screen, all notifications are marked as read',
39
39
  (tester) async {
40
40
  await tester.pumpPage(
41
41
  userState: authenticatedUser(),
42
42
  home: NotificationsPage(),
43
43
  );
44
- await tester.pump(const Duration(milliseconds: 1600));
44
+ // Auto-read fires at 3000ms — give it a little headroom.
45
+ await tester.pump(const Duration(milliseconds: 3100));
45
46
  await tester.pumpAndSettle();
46
47
 
47
48
  final tiles = tester
@@ -61,9 +62,9 @@ void main() {
61
62
  );
62
63
  });
63
64
 
64
- group('NotificationsPage — mark all read button', () {
65
+ group('NotificationsPage — delete all', () {
65
66
  testWidgets(
66
- 'tapping the button marks all notifications as read immediately',
67
+ 'delete all button shows, opens confirm dialog, and clears the list',
67
68
  (tester) async {
68
69
  await tester.pumpPage(
69
70
  userState: authenticatedUser(),
@@ -71,23 +72,72 @@ void main() {
71
72
  );
72
73
  await tester.pumpAndSettle();
73
74
 
74
- // Auto-read may have already run during pumpAndSettle.
75
- // The button is hidden when no unread notifications remain — that is
76
- // expected and itself a correct behavior.
77
- final button = find.byKey(const Key('mark_all_read_button'));
78
- if (button.evaluate().isNotEmpty) {
79
- await tester.tap(button);
80
- await tester.pumpAndSettle();
81
- }
75
+ // Button is visible when there are notifications.
76
+ final deleteAllButton = find.byKey(const Key('delete_all_button'));
77
+ expect(deleteAllButton, findsOneWidget);
82
78
 
83
- final tiles = tester
84
- .widgetList<NotificationTileComponent>(
85
- find.byType(NotificationTileComponent),
86
- )
79
+ await tester.tap(deleteAllButton);
80
+ await tester.pumpAndSettle();
81
+
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(),
131
+ );
132
+ await tester.pumpAndSettle();
133
+
134
+ final dismissibles = tester
135
+ .widgetList<Dismissible>(find.byType(Dismissible))
87
136
  .toList();
88
- expect(tiles, isNotEmpty);
89
- for (final tile in tiles) {
90
- expect(tile.notification.readAt, isNotNull);
137
+ expect(dismissibles, isNotEmpty);
138
+ for (final d in dismissibles) {
139
+ expect(d.direction, DismissDirection.endToStart);
140
+ expect(d.background, isNotNull);
91
141
  }
92
142
  },
93
143
  );