kasy-cli 1.38.0 → 1.39.1

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.
Files changed (105) hide show
  1. package/lib/scaffold/CHANGELOG.json +23 -0
  2. package/lib/scaffold/backends/api/patch/README.md +15 -0
  3. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
  4. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  5. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  6. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  7. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  8. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  9. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  10. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  11. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  12. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  13. package/lib/scaffold/shared/generator-utils.js +12 -6
  14. package/package.json +1 -1
  15. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  16. package/templates/firebase/AGENTS.md +2 -2
  17. package/templates/firebase/DESIGN_SYSTEM.md +23 -8
  18. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  19. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  20. package/templates/firebase/assets/icons/facebook.svg +49 -0
  21. package/templates/firebase/assets/icons/google.svg +1 -0
  22. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  23. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  24. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  25. package/templates/firebase/lib/components/components.dart +5 -2
  26. package/templates/firebase/lib/components/kasy_app_bar.dart +325 -15
  27. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  28. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  29. package/templates/firebase/lib/components/kasy_sidebar.dart +18 -6
  30. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  31. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  32. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +27 -18
  33. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +34 -16
  34. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  35. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  36. package/templates/firebase/lib/core/data/api/user_api.dart +11 -0
  37. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  38. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +95 -30
  39. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  40. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  41. package/templates/firebase/lib/core/states/user_state_notifier.dart +28 -1
  42. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  43. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  44. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +51 -19
  45. package/templates/firebase/lib/core/web_viewport_scale.dart +66 -36
  46. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  47. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  48. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  49. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  50. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  51. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  52. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  53. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  54. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  55. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  56. package/templates/firebase/lib/features/home/home_components_page.dart +253 -125
  57. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +263 -59
  58. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  59. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  60. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  61. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +111 -57
  62. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  63. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -4
  64. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  65. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  66. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  67. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +2 -2
  68. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  69. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  70. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  71. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  72. package/templates/firebase/lib/features/settings/settings_page.dart +53 -32
  73. package/templates/firebase/lib/features/settings/ui/components/admin/admin_home_widgets.dart +4 -0
  74. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +895 -111
  75. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  76. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  77. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +171 -41
  78. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +1 -1
  79. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  80. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +48 -47
  81. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  82. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  83. package/templates/firebase/lib/i18n/en.i18n.json +753 -712
  84. package/templates/firebase/lib/i18n/es.i18n.json +753 -712
  85. package/templates/firebase/lib/i18n/pt.i18n.json +753 -712
  86. package/templates/firebase/lib/main.dart +20 -7
  87. package/templates/firebase/lib/router.dart +32 -26
  88. package/templates/firebase/pubspec.yaml +2 -1
  89. package/templates/firebase/test/admin_shell_chrome_test.dart +11 -5
  90. package/templates/firebase/test/app_bar_config_test.dart +70 -0
  91. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  92. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  93. package/templates/firebase/tool/design_check.dart +9 -0
  94. package/templates/firebase/assets/icons/apple.png +0 -0
  95. package/templates/firebase/assets/icons/facebook.png +0 -0
  96. package/templates/firebase/assets/icons/google.png +0 -0
  97. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  98. package/templates/firebase/lib/components/kasy_web_header.dart +0 -218
  99. package/templates/firebase/lib/core/chrome/web_header_scope.dart +0 -20
  100. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  101. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  102. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  103. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  104. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -179
  105. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
@@ -1,5 +1,6 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+ import 'package:intl/intl.dart';
3
4
  import 'package:kasy_kit/components/kasy_app_bar.dart';
4
5
  import 'package:kasy_kit/components/kasy_bottom_sheet.dart';
5
6
  import 'package:kasy_kit/components/kasy_card.dart';
@@ -7,6 +8,7 @@ import 'package:kasy_kit/components/kasy_chip.dart';
7
8
  import 'package:kasy_kit/components/kasy_date_picker.dart';
8
9
  import 'package:kasy_kit/components/kasy_tabs.dart';
9
10
  import 'package:kasy_kit/core/theme/theme.dart';
11
+ import 'package:kasy_kit/core/widgets/kasy_hover.dart';
10
12
  import 'package:kasy_kit/core/widgets/kasy_scroll_behavior.dart';
11
13
  import 'package:kasy_kit/features/local_reminders/providers/reminder_notifier.dart';
12
14
  import 'package:kasy_kit/features/local_reminders/repositories/reminder_preferences.dart';
@@ -77,14 +79,16 @@ class _ReminderForm extends ConsumerWidget {
77
79
  child: Column(
78
80
  crossAxisAlignment: CrossAxisAlignment.stretch,
79
81
  children: [
82
+ // The master toggle reads like a Settings row: a colored icon
83
+ // badge, the label, and a live one-line summary of when the
84
+ // reminder fires (or a hint when it's off) as the subtitle.
80
85
  KasyCard(
81
- padding: const EdgeInsets.symmetric(
82
- horizontal: KasySpacing.md,
83
- vertical: KasySpacing.xs,
84
- ),
86
+ borderRadius: KasyRadius.lgBorderRadius,
85
87
  child: SettingsSwitchTile(
86
88
  icon: KasyIcons.notification,
89
+ iconBackgroundColor: context.colors.primary,
87
90
  title: tr.toggleLabel,
91
+ subtitle: _scheduleSummary(context, state),
88
92
  value: state.enabled,
89
93
  onChanged: notifier.setEnabled,
90
94
  ),
@@ -93,26 +97,17 @@ class _ReminderForm extends ConsumerWidget {
93
97
  const SizedBox(height: KasySpacing.xl),
94
98
  _FieldLabel(tr.typeLabel),
95
99
  const SizedBox(height: KasySpacing.sm),
96
- // Default hug mode scrolls horizontally when the labels don't
97
- // fit (long localized strings) instead of overflowing the row.
100
+ // Fill mode so the three options split the width equally
101
+ // (a segmented control), instead of hug mode where each tab
102
+ // only takes its own text width and the spacing looks uneven.
98
103
  KasyTabs(
99
104
  tabs: [tr.daily, tr.weekly, tr.specificDate],
100
105
  selectedIndex: _types.indexOf(state.type),
101
106
  onTabSelected: (i) => notifier.setType(_types[i]),
107
+ mode: KasyTabsMode.fill,
102
108
  ),
103
- // daily / weekly schedule by a wall-clock time (hour + minute);
104
- // specificDate carries its own time inside the chosen date.
105
- if (state.type == ReminderType.daily ||
106
- state.type == ReminderType.weekly) ...[
107
- const SizedBox(height: KasySpacing.lg),
108
- _FieldLabel(tr.timeLabel),
109
- const SizedBox(height: KasySpacing.sm),
110
- _TimeTile(
111
- hour: state.hour,
112
- minute: state.minute,
113
- onChanged: (h, m) => notifier.setTime(h, m),
114
- ),
115
- ],
109
+ // Pick the day/date BEFORE the time so the schedule reads in a
110
+ // natural order (which day at what time).
116
111
  if (state.type == ReminderType.weekly) ...[
117
112
  const SizedBox(height: KasySpacing.lg),
118
113
  _FieldLabel(tr.dayLabel),
@@ -149,9 +144,14 @@ class _ReminderForm extends ConsumerWidget {
149
144
  );
150
145
  },
151
146
  ),
152
- const SizedBox(height: KasySpacing.lg),
153
- _FieldLabel(tr.timeLabel),
154
- const SizedBox(height: KasySpacing.sm),
147
+ ],
148
+ // Time is shared by every type. daily / weekly schedule by a
149
+ // wall-clock time (hour + minute); specificDate carries its own
150
+ // time inside the chosen date.
151
+ const SizedBox(height: KasySpacing.lg),
152
+ _FieldLabel(tr.timeLabel),
153
+ const SizedBox(height: KasySpacing.sm),
154
+ if (state.type == ReminderType.specificDate)
155
155
  _TimeTile(
156
156
  hour: state.date?.hour ?? 9,
157
157
  minute: state.date?.minute ?? 0,
@@ -162,8 +162,13 @@ class _ReminderForm extends ConsumerWidget {
162
162
  DateTime(base.year, base.month, base.day, h, m),
163
163
  );
164
164
  },
165
+ )
166
+ else
167
+ _TimeTile(
168
+ hour: state.hour,
169
+ minute: state.minute,
170
+ onChanged: (h, m) => notifier.setTime(h, m),
165
171
  ),
166
- ],
167
172
  ],
168
173
  ],
169
174
  ),
@@ -173,8 +178,43 @@ class _ReminderForm extends ConsumerWidget {
173
178
  }
174
179
  }
175
180
 
181
+ /// Human-readable, one-line description of the active schedule used as the
182
+ /// toggle subtitle. Reads "Every day at 09:00" / "Monday at 09:00" /
183
+ /// "On June 15 at 09:00", and falls back to a hint while the reminder is off
184
+ /// (or asks for a date when a specific-date reminder has none yet).
185
+ String _scheduleSummary(BuildContext context, ReminderState state) {
186
+ final tr = Translations.of(context).reminderPage;
187
+ if (!state.enabled) return tr.hint;
188
+
189
+ final String locale = Localizations.localeOf(context).toString();
190
+ String pad(int v) => v.toString().padLeft(2, '0');
191
+
192
+ switch (state.type) {
193
+ case ReminderType.daily:
194
+ return tr.summaryDaily(time: '${pad(state.hour)}:${pad(state.minute)}');
195
+ case ReminderType.weekly:
196
+ // January 2024 starts on a Monday, so day-of-month N maps to weekday N.
197
+ // DateFormat already cases the weekday per locale (e.g. "Tuesday" vs the
198
+ // lowercase "terça-feira"), so we use it as-is inside the summary.
199
+ final String day =
200
+ DateFormat.EEEE(locale).format(DateTime(2024, 1, state.dayOfWeek));
201
+ return tr.summaryWeekly(
202
+ day: day,
203
+ time: '${pad(state.hour)}:${pad(state.minute)}',
204
+ );
205
+ case ReminderType.specificDate:
206
+ final DateTime? date = state.date;
207
+ if (date == null) return tr.selectDate;
208
+ return tr.summaryDate(
209
+ date: DateFormat.MMMMd(locale).format(date),
210
+ time: '${pad(date.hour)}:${pad(date.minute)}',
211
+ );
212
+ }
213
+ }
214
+
176
215
  /// Quiet group eyebrow above each field — the design-system section label
177
216
  /// (small, gently tracked, muted), matching the Settings / sidebar pattern.
217
+ /// The small left inset aligns it with the Settings section labels.
178
218
  class _FieldLabel extends StatelessWidget {
179
219
  final String label;
180
220
 
@@ -182,10 +222,13 @@ class _FieldLabel extends StatelessWidget {
182
222
 
183
223
  @override
184
224
  Widget build(BuildContext context) {
185
- return Text(
186
- label,
187
- style: context.kasyTextTheme.sectionLabel.copyWith(
188
- color: context.colors.muted,
225
+ return Padding(
226
+ padding: const EdgeInsets.only(left: KasySpacing.xs),
227
+ child: Text(
228
+ label,
229
+ style: context.kasyTextTheme.sectionLabel.copyWith(
230
+ color: context.colors.muted,
231
+ ),
189
232
  ),
190
233
  );
191
234
  }
@@ -199,15 +242,21 @@ class _DaySelector extends StatelessWidget {
199
242
 
200
243
  @override
201
244
  Widget build(BuildContext context) {
202
- // 1=Monday ... 7=Sunday (matches DateTime.weekday)
203
- final days = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb', 'Dom'];
245
+ // 1=Monday ... 7=Sunday (matches DateTime.weekday). Short weekday names are
246
+ // localized via intl, so the chips read correctly in every app language
247
+ // instead of being hardcoded to one locale.
248
+ final DateFormat format = DateFormat.E(
249
+ Localizations.localeOf(context).toString(),
250
+ );
251
+ // January 2024 starts on a Monday, so day-of-month N maps to weekday N (1..7).
252
+ String shortName(int weekday) => format.format(DateTime(2024, 1, weekday));
204
253
  return Wrap(
205
254
  spacing: KasySpacing.xs,
206
255
  runSpacing: KasySpacing.xs,
207
256
  children: List.generate(7, (i) {
208
257
  final dayIndex = i + 1;
209
258
  return KasyChip(
210
- label: days[i],
259
+ label: shortName(dayIndex),
211
260
  selected: current == dayIndex,
212
261
  onSelected: (_) => onChanged(dayIndex),
213
262
  );
@@ -243,35 +292,40 @@ class _TimeTile extends StatelessWidget {
243
292
  }
244
293
  }
245
294
 
295
+ // Same surface + interaction model as a Settings row: an elevated card,
296
+ // a colored icon badge, the value, and a trailing chevron. KasyHover gives
297
+ // the press depth and keyboard focus ring, clipped to the card corners.
246
298
  return KasyCard(
247
- onTap: pickTime,
248
- borderRadius: KasyRadius.mdBorderRadius,
249
- padding: const EdgeInsets.symmetric(
250
- horizontal: KasySpacing.md,
251
- vertical: KasySpacing.smd,
252
- ),
253
- child: Row(
254
- children: [
255
- Icon(
256
- KasyIcons.time,
257
- size: KasyIconSize.lg,
258
- color: context.colors.primary,
259
- ),
260
- const SizedBox(width: KasySpacing.sm),
261
- Text(
262
- '${_pad(hour)}:${_pad(minute)}',
263
- style: context.textTheme.bodyLarge?.copyWith(
264
- color: context.colors.onSurface,
265
- fontWeight: FontWeight.w600,
299
+ borderRadius: KasyRadius.lgBorderRadius,
300
+ child: KasyHover(
301
+ onTap: pickTime,
302
+ focusable: true,
303
+ borderRadius: KasyRadius.lgBorderRadius,
304
+ focusGapColor: context.colors.surface,
305
+ semanticLabel: '${_pad(hour)}:${_pad(minute)}',
306
+ padding: const EdgeInsets.symmetric(
307
+ horizontal: KasySpacing.md,
308
+ vertical: KasySpacing.smd,
309
+ ),
310
+ child: Row(
311
+ children: [
312
+ SettingsIconBadge(
313
+ icon: KasyIcons.time,
314
+ color: context.colors.primary,
266
315
  ),
267
- ),
268
- const Spacer(),
269
- Icon(
270
- KasyIcons.arrowForwardIos,
271
- size: KasyIconSize.xs,
272
- color: context.colors.muted,
273
- ),
274
- ],
316
+ const SizedBox(width: KasySpacing.sm),
317
+ Expanded(
318
+ child: Text(
319
+ '${_pad(hour)}:${_pad(minute)}',
320
+ style: context.kasyTextTheme.listRowTitle.copyWith(
321
+ color: context.colors.onSurface,
322
+ fontWeight: FontWeight.w600,
323
+ ),
324
+ ),
325
+ ),
326
+ const SettingsListChevron(),
327
+ ],
328
+ ),
275
329
  ),
276
330
  );
277
331
  }
@@ -114,9 +114,17 @@ class FirebaseDeviceApi implements DeviceApi {
114
114
  await Future.delayed(const Duration(seconds: 1));
115
115
  }
116
116
  }
117
- final token = await _messaging.getToken();
118
- if (token == null) {
119
- throw ApiError(code: 0, message: 'FCM token is null check Firebase setup and notification permissions');
117
+ // Register the installation even without a push token. On iOS the FCM
118
+ // token stays null until the user grants notification permission (and
119
+ // getToken can even throw before APNS is ready). We still create the
120
+ // device row — so the welcome notification fires and the install is
121
+ // tracked — and the token fills in later via onTokenRefresh once push is
122
+ // enabled.
123
+ String token = '';
124
+ try {
125
+ token = await _messaging.getToken() ?? '';
126
+ } catch (_) {
127
+ token = '';
120
128
  }
121
129
  final os = Platform.isAndroid
122
130
  ? OperatingSystem.android //
@@ -10,10 +10,10 @@ import 'package:kasy_kit/features/notifications/providers/models/notification.da
10
10
  as app;
11
11
  import 'package:kasy_kit/features/notifications/providers/models/notification_list.dart';
12
12
  import 'package:kasy_kit/features/notifications/providers/notifications_provider.dart';
13
- import 'package:kasy_kit/features/notifications/ui/components/notification_settings_sheet.dart';
14
13
  import 'package:kasy_kit/features/notifications/ui/components/notification_tile.dart';
15
14
  import 'package:kasy_kit/features/notifications/ui/widgets/empty_notifications.dart';
16
15
  import 'package:kasy_kit/features/notifications/ui/widgets/notification_tile.dart';
16
+ import 'package:kasy_kit/features/notifications/ui/widgets/push_permission_banner.dart';
17
17
  import 'package:kasy_kit/i18n/translations.g.dart';
18
18
 
19
19
  class NotificationsPage extends ConsumerStatefulWidget {
@@ -124,12 +124,17 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
124
124
  tooltip: t.notifications.delete_all,
125
125
  ),
126
126
  if (hasAny) const SizedBox(width: KasySpacing.xs),
127
+ // Light/dark theme toggle, matching the other root-tab screens.
127
128
  KasyChromeOrbIconButton(
128
- icon: KasyIcons.moreVert,
129
+ icon: Theme.of(ctx).brightness == Brightness.dark
130
+ ? KasyIcons.lightMode
131
+ : KasyIcons.darkMode,
129
132
  iconSize: 18,
130
133
  foregroundColor: ctx.colors.onSurface,
131
- onPressed: () => showNotificationSettingsSheet(ctx),
132
- tooltip: 'Opções',
134
+ onPressed: () => ThemeProvider.of(ctx).toggle(),
135
+ tooltip: Theme.of(ctx).brightness == Brightness.dark
136
+ ? 'Light mode'
137
+ : 'Dark mode',
133
138
  ),
134
139
  ],
135
140
  ),
@@ -139,6 +144,13 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
139
144
  requestReadAll();
140
145
  },
141
146
  slivers: [
147
+ // Native-only push nudge. Self-hides on web and once granted, and the
148
+ // first time it appears in the "never asked" state it fires the native
149
+ // OS prompt automatically. Sits above the list so it shows even when the
150
+ // welcome notification already fills the screen.
151
+ const SliverToBoxAdapter(
152
+ child: PushPermissionBanner(autoRequest: true),
153
+ ),
142
154
  notificationsState.when(
143
155
  loading: () => SliverList(
144
156
  delegate: SliverChildBuilderDelegate(
@@ -1,40 +1,16 @@
1
- import 'package:flutter/foundation.dart' show kIsWeb;
2
1
  import 'package:flutter/material.dart';
3
- import 'package:flutter_riverpod/flutter_riverpod.dart';
4
- import 'package:kasy_kit/components/components.dart';
5
2
  import 'package:kasy_kit/core/theme/theme.dart';
6
- import 'package:kasy_kit/features/notifications/providers/models/notification.dart';
7
- import 'package:kasy_kit/features/notifications/repositories/notifications_repository.dart';
8
3
  import 'package:kasy_kit/i18n/translations.g.dart';
9
4
 
10
- class EmptyNotifications extends ConsumerStatefulWidget {
5
+ /// Empty state for the notifications list: just the illustration and copy.
6
+ ///
7
+ /// The "enable push" call-to-action lives in [PushPermissionBanner] at the top
8
+ /// of the screen now, so it shows whether or not the list is empty (the welcome
9
+ /// notification used to hide the empty-state button). Keeping it in one place
10
+ /// avoids a duplicate CTA.
11
+ class EmptyNotifications extends StatelessWidget {
11
12
  const EmptyNotifications({super.key});
12
13
 
13
- @override
14
- ConsumerState<EmptyNotifications> createState() => _EmptyNotificationsState();
15
- }
16
-
17
- class _EmptyNotificationsState extends ConsumerState<EmptyNotifications> {
18
- late Future<NotificationPermission> _permissionFuture;
19
-
20
- @override
21
- void initState() {
22
- super.initState();
23
- _permissionFuture = _loadPermission();
24
- }
25
-
26
- Future<NotificationPermission> _loadPermission() {
27
- return ref.read(notificationRepositoryProvider).getPermissionStatus();
28
- }
29
-
30
- Future<void> _onAskPressed(NotificationPermission permission) async {
31
- await permission.maybeAsk();
32
- if (!mounted) return;
33
- setState(() {
34
- _permissionFuture = _loadPermission();
35
- });
36
- }
37
-
38
14
  @override
39
15
  Widget build(BuildContext context) {
40
16
  final tr = context.t.notifications;
@@ -74,31 +50,6 @@ class _EmptyNotificationsState extends ConsumerState<EmptyNotifications> {
74
50
  color: context.colors.muted,
75
51
  ),
76
52
  ),
77
- const SizedBox(height: KasySpacing.xl),
78
- FutureBuilder<NotificationPermission>(
79
- future: _permissionFuture,
80
- builder: (context, snapshot) {
81
- final permission = snapshot.data;
82
- if (permission == null) return const SizedBox.shrink();
83
- // Web has no usable notification-permission prompt (askPermission is a
84
- // no-op on web and permission_handler has no web support), so the CTA
85
- // would be a dead button. Show the empty state without it.
86
- if (kIsWeb) return const SizedBox.shrink();
87
- if (permission is NotificationPermissionGranted) {
88
- return const SizedBox.shrink();
89
- }
90
- final isLocked =
91
- permission is NotificationPermissionPermanentlyDenied;
92
- return KasyButton(
93
- label: isLocked ? tr.empty_cta_open_settings : tr.empty_cta,
94
- variant: KasyButtonVariant.soft,
95
- icon: isLocked
96
- ? KasyIcons.settings
97
- : KasyIcons.notificationAdd,
98
- onPressed: () => _onAskPressed(permission),
99
- );
100
- },
101
- ),
102
53
  const SizedBox(height: KasySpacing.xxxl),
103
54
  ],
104
55
  ),
@@ -137,7 +137,7 @@ class NotificationTile extends StatelessWidget {
137
137
  Expanded(
138
138
  child: Text(
139
139
  title,
140
- style: context.textTheme.titleSmall?.copyWith(
140
+ style: context.kasyTextTheme.listRowTitle.copyWith(
141
141
  color: titleColor,
142
142
  fontWeight: FontWeight.w600,
143
143
  ),
@@ -211,11 +211,11 @@ class TileNotificationImage extends StatelessWidget {
211
211
  Widget build(BuildContext context) {
212
212
  return KasyFocusRing(
213
213
  onActivate: () => _openFullscreen(context),
214
- borderRadius: BorderRadius.circular(8),
214
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
215
215
  child: GestureDetector(
216
216
  onTap: () => _openFullscreen(context),
217
217
  child: ClipRRect(
218
- borderRadius: BorderRadius.circular(8),
218
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
219
219
  child: Image.network(
220
220
  url,
221
221
  width: 36,
@@ -252,7 +252,7 @@ class TileNotificationIcon extends StatelessWidget {
252
252
  color: active
253
253
  ? context.colors.primary.withValues(alpha: 0.12)
254
254
  : context.colors.onSurface.withValues(alpha: 0.06),
255
- borderRadius: BorderRadius.circular(10),
255
+ borderRadius: BorderRadius.circular(KasyRadius.md),
256
256
  ),
257
257
  child: Icon(
258
258
  active ? KasyIcons.notificationActive : KasyIcons.notification,
@@ -285,7 +285,7 @@ class NotificationSkeletonTile extends StatelessWidget {
285
285
  KasySkeleton(
286
286
  width: 36,
287
287
  height: 36,
288
- borderRadius: BorderRadius.all(Radius.circular(10)),
288
+ borderRadius: BorderRadius.all(Radius.circular(KasyRadius.md)),
289
289
  ),
290
290
  SizedBox(width: KasySpacing.sm),
291
291
  Expanded(
@@ -0,0 +1,163 @@
1
+ import 'package:flutter/foundation.dart' show kIsWeb;
2
+ import 'package:flutter/material.dart';
3
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+ import 'package:kasy_kit/components/components.dart';
5
+ import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
6
+ import 'package:kasy_kit/core/theme/theme.dart';
7
+ import 'package:kasy_kit/features/notifications/providers/models/notification.dart';
8
+ import 'package:kasy_kit/features/notifications/repositories/notifications_repository.dart';
9
+ import 'package:kasy_kit/i18n/translations.g.dart';
10
+
11
+ /// Inline call-to-action that nudges the user to turn on push notifications.
12
+ ///
13
+ /// Push is native-only: on web this renders nothing (permission_handler has no
14
+ /// web support and there is no native prompt). It also renders nothing once
15
+ /// permission is granted, so it self-hides the moment it's no longer needed.
16
+ ///
17
+ /// States while not granted (native only):
18
+ /// - never asked / denied (Android can re-ask) → "Enable notifications",
19
+ /// which fires the native OS prompt directly (no custom pre-dialog: the OS
20
+ /// already shows its own localized prompt).
21
+ /// - permanently denied (iOS after a refusal) → "Open settings", since the OS
22
+ /// won't show the native prompt again and the only way back is the system
23
+ /// settings of the app.
24
+ ///
25
+ /// When [autoRequest] is true, the first time it mounts in the "never asked"
26
+ /// state it fires the native prompt automatically — once per install, guarded
27
+ /// by a shared-preferences flag. Used on the notifications screen so simply
28
+ /// arriving there surfaces the native prompt.
29
+ class PushPermissionBanner extends ConsumerStatefulWidget {
30
+ const PushPermissionBanner({super.key, this.autoRequest = false});
31
+
32
+ /// Auto-fire the native prompt once when first shown in the "never asked"
33
+ /// state. Leave false to require an explicit tap on the CTA.
34
+ final bool autoRequest;
35
+
36
+ @override
37
+ ConsumerState<PushPermissionBanner> createState() =>
38
+ _PushPermissionBannerState();
39
+ }
40
+
41
+ class _PushPermissionBannerState extends ConsumerState<PushPermissionBanner>
42
+ with WidgetsBindingObserver {
43
+ NotificationPermission? _permission;
44
+ bool _busy = false;
45
+
46
+ @override
47
+ void initState() {
48
+ super.initState();
49
+ WidgetsBinding.instance.addObserver(this);
50
+ _init();
51
+ }
52
+
53
+ @override
54
+ void dispose() {
55
+ WidgetsBinding.instance.removeObserver(this);
56
+ super.dispose();
57
+ }
58
+
59
+ @override
60
+ void didChangeAppLifecycleState(AppLifecycleState state) {
61
+ // Returning from the OS settings: re-check so the banner self-hides if the
62
+ // user just enabled notifications, or comes back if they disabled them —
63
+ // without the user having to tap anything.
64
+ if (state == AppLifecycleState.resumed && !kIsWeb) {
65
+ _reload();
66
+ }
67
+ }
68
+
69
+ Future<void> _init() async {
70
+ // Push is native-only — nothing to do (and nothing to render) on web.
71
+ if (kIsWeb) return;
72
+ final permission =
73
+ await ref.read(notificationRepositoryProvider).getPermissionStatus();
74
+ if (!mounted) return;
75
+
76
+ if (widget.autoRequest && permission is NotificationPermissionWaiting) {
77
+ final prefs = ref.read(sharedPreferencesProvider);
78
+ if (!prefs.getPushAutoRequested()) {
79
+ await prefs.setPushAutoRequested(true);
80
+ await permission.maybeAsk();
81
+ await _reload();
82
+ return;
83
+ }
84
+ }
85
+ setState(() => _permission = permission);
86
+ }
87
+
88
+ Future<void> _reload() async {
89
+ final permission =
90
+ await ref.read(notificationRepositoryProvider).getPermissionStatus();
91
+ if (mounted) setState(() => _permission = permission);
92
+ }
93
+
94
+ Future<void> _onPressed() async {
95
+ if (_busy || _permission == null) return;
96
+ setState(() => _busy = true);
97
+ await _permission!.maybeAsk();
98
+ await _reload();
99
+ if (mounted) setState(() => _busy = false);
100
+ }
101
+
102
+ @override
103
+ Widget build(BuildContext context) {
104
+ // Native-only, and only while permission is still missing.
105
+ if (kIsWeb) return const SizedBox.shrink();
106
+ final permission = _permission;
107
+ if (permission == null || permission is NotificationPermissionGranted) {
108
+ return const SizedBox.shrink();
109
+ }
110
+
111
+ final tr = context.t.notifications;
112
+ final bool locked = permission is NotificationPermissionPermanentlyDenied;
113
+
114
+ // Discreet single-row nudge: small muted bell + one line of copy + a compact
115
+ // neutral button. Intentionally low-key so it doesn't compete with the list.
116
+ return Padding(
117
+ padding: const EdgeInsets.only(bottom: KasySpacing.smd),
118
+ child: Container(
119
+ padding: const EdgeInsets.fromLTRB(
120
+ KasySpacing.md,
121
+ KasySpacing.sm,
122
+ KasySpacing.sm,
123
+ KasySpacing.sm,
124
+ ),
125
+ decoration: BoxDecoration(
126
+ color: context.colors.surface,
127
+ borderRadius: KasyRadius.mdBorderRadius,
128
+ border: Border.all(
129
+ color: context.colors.outline.withValues(alpha: 0.4),
130
+ ),
131
+ ),
132
+ child: Row(
133
+ children: [
134
+ Icon(
135
+ KasyIcons.notification,
136
+ size: KasyIconSize.rowLeading,
137
+ color: context.colors.muted,
138
+ ),
139
+ const SizedBox(width: KasySpacing.sm),
140
+ Expanded(
141
+ child: Text(
142
+ locked ? tr.push_subtitle_disabled : tr.push_subtitle_waiting,
143
+ style: context.textTheme.bodySmall?.copyWith(
144
+ color: context.colors.onSurface.withValues(alpha: 0.75),
145
+ ),
146
+ maxLines: 2,
147
+ overflow: TextOverflow.ellipsis,
148
+ ),
149
+ ),
150
+ const SizedBox(width: KasySpacing.sm),
151
+ KasyButton(
152
+ label: locked ? tr.empty_cta_open_settings : tr.empty_cta,
153
+ variant: KasyButtonVariant.outline,
154
+ size: KasyButtonSize.small,
155
+ isLoading: _busy,
156
+ onPressed: _busy ? null : _onPressed,
157
+ ),
158
+ ],
159
+ ),
160
+ ),
161
+ );
162
+ }
163
+ }
@@ -16,8 +16,8 @@ import 'package:kasy_kit/i18n/translations.g.dart';
16
16
  /// `/notifications` — the panel's "See all" link goes there.
17
17
  ///
18
18
  /// Owns its own overlay (OverlayPortal + LayerLink, the same popover technique
19
- /// as [KasySidebar]), so [KasyWebHeader] stays a pure presentational component
20
- /// and the data wiring lives here.
19
+ /// as [KasySidebar]), so [KasyAppBar.application] stays a pure presentational
20
+ /// component and the data wiring lives here.
21
21
  class WebNotificationsBell extends ConsumerStatefulWidget {
22
22
  const WebNotificationsBell({super.key});
23
23
 
@@ -9,18 +9,27 @@ class OnboardingState {
9
9
  /// here and flush them once the account is created.
10
10
  final List<UserInfoDetail> pendingUserInfo;
11
11
 
12
+ /// Preview mode: the flow is being walked from the admin Debug screen just to
13
+ /// see the screens. Every real side effect (guest account creation, profile
14
+ /// writes, permission prompts, the onboarded flag) is suppressed and the flow
15
+ /// returns to Debug instead of Home. See [OnboardingNotifier].
16
+ final bool preview;
17
+
12
18
  OnboardingState({
13
19
  this.reminder,
14
20
  this.pendingUserInfo = const [],
21
+ this.preview = false,
15
22
  });
16
23
 
17
24
  OnboardingState copyWith({
18
25
  DateTime? reminder,
19
26
  List<UserInfoDetail>? pendingUserInfo,
27
+ bool? preview,
20
28
  }) {
21
29
  return OnboardingState(
22
30
  reminder: reminder ?? this.reminder,
23
31
  pendingUserInfo: pendingUserInfo ?? this.pendingUserInfo,
32
+ preview: preview ?? this.preview,
24
33
  );
25
34
  }
26
35
  }