kasy-cli 1.37.1 → 1.38.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.
Files changed (49) hide show
  1. package/lib/scaffold/CHANGELOG.json +9 -0
  2. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  3. package/lib/scaffold/backends/patch-base-hashes.json +2 -2
  4. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  5. package/package.json +1 -1
  6. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  7. package/templates/firebase/AGENTS.md +7 -1
  8. package/templates/firebase/DESIGN_SYSTEM.md +13 -0
  9. package/templates/firebase/lib/components/kasy_app_bar.dart +57 -16
  10. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  11. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  12. package/templates/firebase/lib/components/kasy_sidebar.dart +394 -25
  13. package/templates/firebase/lib/components/kasy_web_header.dart +11 -3
  14. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +2 -213
  15. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  16. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +12 -4
  17. package/templates/firebase/lib/core/chrome/web_header_scope.dart +20 -0
  18. package/templates/firebase/lib/core/data/api/user_api.dart +4 -0
  19. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  20. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  21. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  22. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  23. package/templates/firebase/lib/core/states/user_state_notifier.dart +41 -0
  24. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  25. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  26. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  27. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  28. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  29. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +498 -466
  30. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  31. package/templates/firebase/lib/features/home/home_components_page.dart +16 -6
  32. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +7 -0
  33. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +23 -13
  34. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -0
  35. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  36. package/templates/firebase/lib/features/settings/settings_page.dart +51 -38
  37. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +549 -376
  38. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  39. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +266 -141
  40. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +23 -30
  41. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +30 -49
  42. package/templates/firebase/lib/i18n/en.i18n.json +23 -9
  43. package/templates/firebase/lib/i18n/es.i18n.json +23 -9
  44. package/templates/firebase/lib/i18n/pt.i18n.json +23 -9
  45. package/templates/firebase/lib/router.dart +43 -25
  46. package/templates/firebase/pubspec.yaml +1 -1
  47. package/templates/firebase/test/admin_shell_chrome_test.dart +104 -0
  48. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  49. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -7,11 +7,12 @@ import 'package:go_router/go_router.dart';
7
7
  import 'package:kasy_kit/components/kasy_app_bar.dart';
8
8
  import 'package:kasy_kit/components/kasy_bottom_sheet.dart';
9
9
  import 'package:kasy_kit/components/kasy_button.dart';
10
+ import 'package:kasy_kit/components/kasy_sidebar.dart';
10
11
  import 'package:kasy_kit/components/kasy_status_tag.dart';
11
- import 'package:kasy_kit/components/kasy_tabs.dart';
12
12
  import 'package:kasy_kit/components/kasy_text_field.dart';
13
13
  import 'package:kasy_kit/core/app_update/update_available_sheet.dart';
14
- import 'package:kasy_kit/core/config/features.dart';
14
+ import 'package:kasy_kit/core/bottom_menu/sidebar_focus.dart';
15
+ import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
15
16
  import 'package:kasy_kit/core/data/models/user.dart';
16
17
  import 'package:kasy_kit/core/dev_inspector/dev_inspector.dart';
17
18
  import 'package:kasy_kit/core/rating/widgets/review_popup.dart';
@@ -19,46 +20,171 @@ import 'package:kasy_kit/core/states/user_state_notifier.dart';
19
20
  import 'package:kasy_kit/core/theme/theme.dart';
20
21
  import 'package:kasy_kit/core/toast/toast_service.dart';
21
22
  import 'package:kasy_kit/core/web_device_preview/web_device_preview.dart';
23
+ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
22
24
  import 'package:kasy_kit/core/widgets/update_bottom_sheet.dart';
23
25
  import 'package:kasy_kit/features/feedbacks/api/entities/feature_request_entity.dart';
24
26
  import 'package:kasy_kit/features/feedbacks/api/feature_request_api.dart';
27
+ import 'package:kasy_kit/features/home/home_components_page.dart';
25
28
  import 'package:kasy_kit/features/notifications/api/local_notifier.dart';
26
29
  import 'package:kasy_kit/features/notifications/providers/models/notification.dart'
27
30
  as kasy_kit;
28
31
  import 'package:kasy_kit/features/settings/ui/components/admin/admin_routes.dart';
29
32
  import 'package:kasy_kit/features/settings/ui/components/admin/admin_users_tab.dart';
33
+ import 'package:kasy_kit/features/settings/ui/components/admin/send_push_notification_page.dart';
34
+ import 'package:kasy_kit/features/settings/ui/widgets/kasy_user_avatar.dart';
30
35
  import 'package:kasy_kit/features/settings/ui/widgets/settings_tile.dart';
36
+ import 'package:kasy_kit/features/subscriptions/ui/component/premium_page_factory.dart';
31
37
  import 'package:kasy_kit/i18n/translations.g.dart';
32
38
  import 'package:kasy_kit/router.dart';
33
39
  import 'package:package_info_plus/package_info_plus.dart';
34
- import 'package:shared_preferences/shared_preferences.dart';
35
40
 
36
- /// Full-screen developer admin console (debug only).
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+ // Admin sections — single source of truth shared by the router (URL branches)
43
+ // and the sidebar (nav items), so the two can never drift out of sync.
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+
46
+ /// A navigable admin section. Each maps to a real URL branch in the router.
47
+ ///
48
+ /// The first three are top-level rows under the "ADMIN" label; the rest live
49
+ /// inside the sidebar's expandable "Ferramentas" submenu
50
+ /// ([AdminSectionDef.inToolsGroup]). [components] and [debug] are developer-only
51
+ /// (their branches register only in [kDebugMode]).
52
+ enum AdminSection {
53
+ overview,
54
+ users,
55
+ requests,
56
+ sendPush,
57
+ paywalls,
58
+ components,
59
+ debug,
60
+ }
61
+
62
+ /// Descriptor for one admin section: its real URL [path], sidebar [icon] and the
63
+ /// [build]er for its (body-only) view. [inToolsGroup] places the row inside the
64
+ /// sidebar's "Ferramentas" submenu instead of the top-level list.
65
+ class AdminSectionDef {
66
+ final AdminSection id;
67
+ final String path;
68
+ final IconData icon;
69
+ final Widget Function() build;
70
+ final bool inToolsGroup;
71
+ const AdminSectionDef({
72
+ required this.id,
73
+ required this.path,
74
+ required this.icon,
75
+ required this.build,
76
+ this.inToolsGroup = false,
77
+ });
78
+ }
79
+
80
+ /// Base URL of the admin console. Every section is this or a child of it, so the
81
+ /// router's security guard can match the whole area with a single prefix.
82
+ const String adminBasePath = '/admin';
83
+
84
+ /// The admin sections, in sidebar/URL order. The four "Ferramentas" sub-screens
85
+ /// are real sections too (own branch, own URL, persistent rail) — Send push and
86
+ /// Paywalls ship in production; Components and Debug are developer-only and only
87
+ /// appear in debug builds. The router (branches) and the sidebar (nav rows) both
88
+ /// read this single list, so they can never drift.
89
+ List<AdminSectionDef> adminSections() => [
90
+ AdminSectionDef(
91
+ id: AdminSection.overview,
92
+ path: adminBasePath,
93
+ icon: KasyIcons.dashboard,
94
+ build: () => const _OverviewTab(),
95
+ ),
96
+ AdminSectionDef(
97
+ id: AdminSection.users,
98
+ path: '$adminBasePath/users',
99
+ icon: KasyIcons.users,
100
+ build: () => const _UsersTab(),
101
+ ),
102
+ AdminSectionDef(
103
+ id: AdminSection.requests,
104
+ path: '$adminBasePath/requests',
105
+ icon: KasyIcons.idea,
106
+ build: () => const _RequestsTab(),
107
+ ),
108
+ // ── "Ferramentas" submenu ───────────────────────────────────────────────
109
+ AdminSectionDef(
110
+ id: AdminSection.sendPush,
111
+ path: adminRouteSendPush,
112
+ icon: KasyIcons.notificationActive,
113
+ build: () => const SendPushNotificationPage(),
114
+ inToolsGroup: true,
115
+ ),
116
+ AdminSectionDef(
117
+ id: AdminSection.paywalls,
118
+ path: adminRoutePaywalls,
119
+ icon: KasyIcons.payment,
120
+ build: () => const _PaywallsTab(),
121
+ inToolsGroup: true,
122
+ ),
123
+ if (kDebugMode) ...[
124
+ AdminSectionDef(
125
+ id: AdminSection.components,
126
+ path: adminRouteComponents,
127
+ icon: KasyIcons.widgets,
128
+ build: () => const _ComponentsTab(),
129
+ inToolsGroup: true,
130
+ ),
131
+ AdminSectionDef(
132
+ id: AdminSection.debug,
133
+ path: adminRouteDebug,
134
+ icon: KasyIcons.note,
135
+ build: () => const _DebugTab(),
136
+ inToolsGroup: true,
137
+ ),
138
+ ],
139
+ ];
140
+
141
+ /// Localized sidebar / app-bar label for a section.
142
+ String adminSectionLabel(AdminSection id) {
143
+ final tabs = t.admin_console.tabs;
144
+ return switch (id) {
145
+ AdminSection.overview => tabs.overview,
146
+ AdminSection.users => tabs.users,
147
+ AdminSection.requests => tabs.requests,
148
+ AdminSection.sendPush => t.home.features_page.send_push_title,
149
+ AdminSection.paywalls => t.settings.admin.paywalls,
150
+ AdminSection.components => t.home.dashboard.components_title,
151
+ AdminSection.debug => tabs.debug,
152
+ };
153
+ }
154
+
155
+ /// Persistent chrome for the admin console: the navigation rail (sidebar on
156
+ /// desktop, drawer on mobile) wrapping the routed section content. Each section
157
+ /// is a real, URL-addressable screen ([adminSections]); the rail reflects and
158
+ /// drives [StatefulNavigationShell.currentIndex] so the browser URL, the back
159
+ /// button and web state-restoration all behave like the rest of the app.
37
160
  ///
38
- /// Vercel-style layout: frosted "Admin" app bar + horizontally-scrollable
39
- /// underline tabs over a content column that is centred and width-capped so the
40
- /// console reads as a real dashboard on web/desktop instead of a stretched
41
- /// phone screen. Replaces the old admin bottom sheet — reached via the
42
- /// double-tap on the settings version label; popping returns to Settings.
161
+ /// Reached by `context.push('/admin')` (double-tap on the settings version
162
+ /// label); popping returns to the app. Built from the same design system as the
163
+ /// real [KasySidebar]/[KasyAppBar] so it reads as native product chrome.
43
164
  ///
44
- /// TOP-LEVEL route (sibling of '/', outside the BottomMenu shell) so it never
45
- /// renders the web sidebar — the admin is a dev tool, not product chrome.
46
- class AdminPage extends ConsumerStatefulWidget {
47
- const AdminPage({super.key});
165
+ /// Access: the router redirects non-admins away from `/admin*` in production
166
+ /// (see the guard in `router.dart`) — the real lock is at the routing layer, not
167
+ /// just hidden UI. In debug anyone reaches it; server-backed sections still gate
168
+ /// their data by role internally.
169
+ class AdminShell extends ConsumerStatefulWidget {
170
+ final StatefulNavigationShell navigationShell;
171
+ const AdminShell({super.key, required this.navigationShell});
48
172
 
49
173
  @override
50
- ConsumerState<AdminPage> createState() => _AdminPageState();
174
+ ConsumerState<AdminShell> createState() => _AdminShellState();
51
175
  }
52
176
 
53
- class _AdminPageState extends ConsumerState<AdminPage> {
54
- int _tab = 0;
177
+ class _AdminShellState extends ConsumerState<AdminShell> {
178
+ final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
55
179
 
56
180
  @override
57
181
  Widget build(BuildContext context) {
58
182
  final bool isAdmin = ref.watch(userStateNotifierProvider).user.isAdmin;
59
183
  final ac = t.admin_console;
60
184
 
61
- // The console itself opens for admins (even in release) or anyone in debug.
185
+ // Defense in depth: the router already redirects non-admins away from
186
+ // /admin* in production, so this only ever renders in the unexpected case
187
+ // the shell is reached without the role (never in a release build).
62
188
  if (!isAdmin && !kDebugMode) {
63
189
  return Scaffold(
64
190
  backgroundColor: context.colors.background,
@@ -68,7 +194,7 @@ class _AdminPageState extends ConsumerState<AdminPage> {
68
194
  const KasyAppBar(title: 'Admin'),
69
195
  Expanded(
70
196
  child: _EmptyState(
71
- icon: Icons.lock_outline_rounded,
197
+ icon: KasyIcons.security,
72
198
  title: ac.tabs.overview,
73
199
  message: ac.requires_admin,
74
200
  ),
@@ -78,43 +204,159 @@ class _AdminPageState extends ConsumerState<AdminPage> {
78
204
  );
79
205
  }
80
206
 
81
- // Server-data tabs always present (gated by role inside). Dev tabs (Kit,
82
- // Tools) only in debug — they are developer conveniences, not admin data.
83
- final List<({String label, Widget view})> entries = [
84
- (label: ac.tabs.overview, view: const _OverviewTab()),
85
- (label: ac.tabs.users, view: const _UsersTab()),
86
- (label: ac.tabs.requests, view: const _RequestsTab()),
87
- if (kDebugMode) (label: ac.tabs.kit, view: const _KitTab()),
88
- if (kDebugMode) (label: ac.tabs.tools, view: const _ToolsTab()),
89
- ];
90
- final int tab = _tab.clamp(0, entries.length - 1);
207
+ final List<AdminSectionDef> sections = adminSections();
208
+ final int index = widget.navigationShell.currentIndex;
209
+ final double width = MediaQuery.sizeOf(context).width;
210
+ // Same breakpoints as the app shell (bottom_menu.dart +
211
+ // web_content_wrapper.dart): the sidebar shows from tablet up (collapsing to
212
+ // the icon rail on tablet), and the web header replaces the app bar only on
213
+ // desktop.
214
+ final bool hasSidebar = width >= DeviceType.medium.breakpoint;
215
+
216
+ void selectEntry(int i) {
217
+ // Re-tapping the active section resets its branch to the root, the
218
+ // standard StatefulShellRoute / tab-bar behaviour.
219
+ widget.navigationShell.goBranch(
220
+ i,
221
+ initialLocation: i == widget.navigationShell.currentIndex,
222
+ );
223
+ if (!hasSidebar) _scaffoldKey.currentState?.closeDrawer();
224
+ }
225
+
226
+ // Profile block in the rail, populated exactly like the app sidebar
227
+ // (bottom_menu.dart): the real name/email + the signed-in user's avatar.
228
+ final User user = ref.watch(userStateNotifierProvider).user;
229
+ final (String profileName, String profileEmail) = switch (user) {
230
+ final AuthenticatedUserData u => (
231
+ (u.name?.isNotEmpty ?? false) ? u.name! : u.email.split('@').first,
232
+ u.email,
233
+ ),
234
+ _ => (t.settings.my_account, ''),
235
+ };
236
+
237
+ // The app's real KasySidebar, driven by the admin sections (same component,
238
+ // same logo/dividers/collapse/tooltips/profile — no bespoke copy). The
239
+ // top-level sections are flat rows; the four Tools sub-screens live inside an
240
+ // expandable "Ferramentas" submenu (the same dropdown recipe as the app's
241
+ // Income menu), so each keeps the rail and its own URL.
242
+ KasySidebar buildRail({required bool isDrawer}) {
243
+ final List<KasySidebarItem> items = [
244
+ for (int i = 0; i < sections.length; i++)
245
+ if (!sections[i].inToolsGroup)
246
+ KasySidebarItem(
247
+ icon: sections[i].icon,
248
+ label: adminSectionLabel(sections[i].id),
249
+ selected: i == index,
250
+ onTap: () => selectEntry(i),
251
+ ),
252
+ ];
253
+ final List<KasySidebarSubItem> toolsChildren = [
254
+ for (int i = 0; i < sections.length; i++)
255
+ if (sections[i].inToolsGroup)
256
+ KasySidebarSubItem(
257
+ label: adminSectionLabel(sections[i].id),
258
+ selected: i == index,
259
+ onTap: () => selectEntry(i),
260
+ ),
261
+ ];
262
+ if (toolsChildren.isNotEmpty) {
263
+ items.add(
264
+ KasySidebarItem(
265
+ icon: KasyIcons.briefcase,
266
+ label: t.admin_console.tabs.tools,
267
+ children: toolsChildren,
268
+ ),
269
+ );
270
+ }
271
+ return KasySidebar(
272
+ isDrawer: isDrawer,
273
+ items: items,
274
+ sectionLabel: 'ADMIN',
275
+ footerItems: [
276
+ KasySidebarItem(
277
+ icon: KasyIcons.arrowBackIos,
278
+ label: ac.back_to_app,
279
+ onTap: () {
280
+ if (!hasSidebar) _scaffoldKey.currentState?.closeDrawer();
281
+ _backToApp(context);
282
+ },
283
+ ),
284
+ ],
285
+ profileName: profileName,
286
+ profileEmail: profileEmail,
287
+ profileAvatar: const KasyUserAvatar(),
288
+ );
289
+ }
290
+
291
+ final Widget content = widget.navigationShell;
292
+
293
+ // Tablet + desktop: the real KasySidebar persists and EVERY section gets the
294
+ // same chrome as the rest of the app (bottom_menu.dart): the web header on
295
+ // desktop / the rootTab app bar on tablet, both supplied here so the section
296
+ // bodies stay chrome-free. Keyboard order matches the app shell:
297
+ // OrderedTraversalPolicy flows Tab sidebar -> header -> content;
298
+ // KasyFocusableSidebar anchors the initial focus to the rail and hosts the
299
+ // skip-to-content link; WebContentWrapper carries the desktop web header +
300
+ // the content focus target (orders 2 and 3) exactly like every app page.
301
+ if (hasSidebar) {
302
+ final Widget right = WebContentWrapper(
303
+ child: Column(
304
+ crossAxisAlignment: CrossAxisAlignment.stretch,
305
+ children: [
306
+ KasyAppBar(
307
+ title: adminSectionLabel(sections[index].id),
308
+ style: KasyAppBarStyle.rootTab,
309
+ onThemeToggle: () => ThemeProvider.of(context).toggle(),
310
+ ),
311
+ Expanded(child: content),
312
+ ],
313
+ ),
314
+ );
315
+ return Scaffold(
316
+ backgroundColor: context.colors.background,
317
+ body: FocusTraversalGroup(
318
+ policy: OrderedTraversalPolicy(),
319
+ child: Row(
320
+ crossAxisAlignment: CrossAxisAlignment.stretch,
321
+ children: [
322
+ FocusTraversalOrder(
323
+ order: const NumericFocusOrder(1),
324
+ child: KasyFocusableSidebar(child: buildRail(isDrawer: false)),
325
+ ),
326
+ Expanded(child: right),
327
+ ],
328
+ ),
329
+ ),
330
+ );
331
+ }
91
332
 
333
+ // Phone: the page app bar (with a menu orb) over a drawer holding the same
334
+ // KasySidebar — the standard mobile pattern.
92
335
  return Scaffold(
336
+ key: _scaffoldKey,
93
337
  backgroundColor: context.colors.background,
338
+ // Square edge / flat / surface fill all come from the global DrawerThemeData
339
+ // (core/theme/universal_theme.dart); here we only size it to the rail.
340
+ drawer: Drawer(
341
+ width: kasySidebarWidth,
342
+ child: buildRail(isDrawer: true),
343
+ ),
94
344
  body: Column(
95
345
  crossAxisAlignment: CrossAxisAlignment.stretch,
96
346
  children: [
97
- const KasyAppBar(title: 'Admin'),
98
- const SizedBox(height: KasySpacing.sm),
99
- _MaxWidth(
100
- child: Padding(
101
- padding: const EdgeInsets.symmetric(
102
- horizontal: KasySpacing.pageHorizontalGutter,
103
- ),
104
- child: KasyTabs(
105
- tabs: [for (final e in entries) e.label],
106
- selectedIndex: tab,
107
- onTabSelected: (i) => setState(() => _tab = i),
108
- variant: KasyTabsVariant.secondary,
347
+ KasyAppBar(
348
+ title: adminSectionLabel(sections[index].id),
349
+ leading: Builder(
350
+ builder: (ctx) => KasyChromeOrbIconButton(
351
+ icon: KasyIcons.menu,
352
+ iconSize: KasyIconSize.md,
353
+ foregroundColor: context.colors.onSurface,
354
+ onPressed: () => Scaffold.of(ctx).openDrawer(),
355
+ tooltip: MaterialLocalizations.of(ctx).openAppDrawerTooltip,
109
356
  ),
110
357
  ),
111
358
  ),
112
- Expanded(
113
- child: IndexedStack(
114
- index: tab,
115
- children: [for (final e in entries) e.view],
116
- ),
117
- ),
359
+ Expanded(child: content),
118
360
  ],
119
361
  ),
120
362
  );
@@ -296,110 +538,21 @@ class _StatCard extends StatelessWidget {
296
538
  }
297
539
  }
298
540
 
299
- /// Tappable action card: icon bubble + title/subtitle + chevron, with ripple.
300
- class _ActionCard extends StatelessWidget {
301
- final IconData icon;
302
- final String title;
303
- final String? subtitle;
304
- final VoidCallback onTap;
305
- final Color? tone;
306
- const _ActionCard({
307
- required this.icon,
308
- required this.title,
309
- required this.onTap,
310
- this.subtitle,
311
- this.tone,
312
- });
313
-
314
- @override
315
- Widget build(BuildContext context) {
316
- final BorderRadius radius = BorderRadius.circular(_cardRadius);
317
- final Color accent = tone ?? context.colors.primary;
318
- return DecoratedBox(
319
- decoration: BoxDecoration(
320
- borderRadius: radius,
321
- boxShadow: [KasyShadows.component(context)],
322
- ),
323
- child: Material(
324
- color: context.colors.surface,
325
- borderRadius: radius,
326
- clipBehavior: Clip.antiAlias,
327
- child: InkWell(
328
- onTap: onTap,
329
- child: Container(
330
- decoration: BoxDecoration(
331
- borderRadius: radius,
332
- border: Border.all(
333
- color: context.colors.outline.withValues(
334
- alpha: context.isDark ? 0.45 : 0.6,
335
- ),
336
- ),
337
- ),
338
- padding: const EdgeInsets.all(KasySpacing.md),
339
- child: Row(
340
- children: [
341
- _IconBubble(icon: icon, tone: accent),
342
- const SizedBox(width: KasySpacing.smd),
343
- Expanded(
344
- child: Column(
345
- crossAxisAlignment: CrossAxisAlignment.start,
346
- mainAxisSize: MainAxisSize.min,
347
- children: [
348
- Text(
349
- title,
350
- maxLines: 1,
351
- overflow: TextOverflow.ellipsis,
352
- style: context.textTheme.titleSmall?.copyWith(
353
- color: context.colors.onSurface,
354
- fontWeight: FontWeight.w700,
355
- ),
356
- ),
357
- if (subtitle != null) ...[
358
- const SizedBox(height: 2),
359
- Text(
360
- subtitle!,
361
- maxLines: 2,
362
- overflow: TextOverflow.ellipsis,
363
- style: context.textTheme.bodySmall?.copyWith(
364
- color: context.colors.muted,
365
- height: 1.3,
366
- ),
367
- ),
368
- ],
369
- ],
370
- ),
371
- ),
372
- const SizedBox(width: KasySpacing.sm),
373
- Icon(
374
- KasyIcons.chevronRight,
375
- size: 18,
376
- color: context.colors.muted,
377
- ),
378
- ],
379
- ),
380
- ),
381
- ),
382
- ),
383
- );
384
- }
385
- }
386
-
387
541
  /// Lays children out in as many equal columns as fit [minItemWidth], capped at
388
- /// [maxCols]. Collapses to a single full-width column on narrow screens.
542
+ /// four. Collapses to a single full-width column on narrow screens.
389
543
  class _ResponsiveGrid extends StatelessWidget {
390
544
  final List<Widget> children;
391
545
  final double minItemWidth;
392
- final int maxCols;
393
546
  const _ResponsiveGrid({
394
547
  required this.children,
395
548
  this.minItemWidth = 240,
396
- this.maxCols = 4,
397
549
  });
398
550
 
399
551
  @override
400
552
  Widget build(BuildContext context) {
401
553
  if (children.isEmpty) return const SizedBox.shrink();
402
554
  const double gap = KasySpacing.md;
555
+ const int maxCols = 4;
403
556
  return LayoutBuilder(
404
557
  builder: (context, constraints) {
405
558
  final double maxW = constraints.maxWidth;
@@ -420,10 +573,18 @@ class _ResponsiveGrid extends StatelessWidget {
420
573
  }
421
574
  }
422
575
 
423
- /// Pops the admin page back to Settings used by actions that historically
424
- /// closed the bottom sheet so their result is visible over the app.
576
+ /// Leaves the admin console entirely, back to the app. Pops the ROOT navigator
577
+ /// so the whole admin shell is removed at once even when a detail screen is
578
+ /// open in a section's nested navigator (a plain pop would only close the
579
+ /// detail). Falls back to going home when it can't pop (e.g. a web reload / deep
580
+ /// link that landed directly on an /admin URL).
425
581
  void _backToApp(BuildContext context) {
426
- if (context.canPop()) context.pop();
582
+ final NavigatorState root = Navigator.of(context, rootNavigator: true);
583
+ if (root.canPop()) {
584
+ root.pop();
585
+ } else {
586
+ context.go('/');
587
+ }
427
588
  }
428
589
 
429
590
  // ─────────────────────────────────────────────────────────────────────────────
@@ -440,6 +601,8 @@ class _OverviewTab extends ConsumerWidget {
440
601
  final bool isAuth = user is AuthenticatedUserData;
441
602
  final bool isAdmin = user.isAdmin;
442
603
  final ov = t.admin_console.overview;
604
+ final admin = t.settings.admin;
605
+ final groups = t.admin_console.groups;
443
606
  final String account = isAuth ? user.email : ov.guest;
444
607
  final String uid = userState.user.idOrNull ?? '—';
445
608
 
@@ -450,7 +613,7 @@ class _OverviewTab extends ConsumerWidget {
450
613
  minItemWidth: 200,
451
614
  children: [
452
615
  _StatCard(
453
- icon: Icons.cloud_done_rounded,
616
+ icon: KasyIcons.monitor,
454
617
  tone: context.colors.primary,
455
618
  value: 'Firebase',
456
619
  label: ov.backend,
@@ -458,7 +621,7 @@ class _OverviewTab extends ConsumerWidget {
458
621
  // Feature-request count reads the server — admins only.
459
622
  if (isAdmin)
460
623
  _StatCard(
461
- icon: Icons.how_to_vote_rounded,
624
+ icon: KasyIcons.voteUp,
462
625
  tone: context.colors.primary,
463
626
  value: ref.watch(_adminRequestsProvider).maybeWhen(
464
627
  data: (l) => '${l.length}',
@@ -509,6 +672,33 @@ class _OverviewTab extends ConsumerWidget {
509
672
  ],
510
673
  ),
511
674
  ),
675
+ // Dev preview tools are toggled by keyboard shortcut (no buttons). Shown
676
+ // only in debug — never in production.
677
+ if (kDebugMode) ...[
678
+ const SizedBox(height: KasySpacing.lg),
679
+ _GroupLabel(groups.preview),
680
+ _CardShell(
681
+ padding: const EdgeInsets.symmetric(
682
+ horizontal: KasySpacing.md,
683
+ vertical: KasySpacing.xs,
684
+ ),
685
+ child: Column(
686
+ children: [
687
+ _ShortcutRow(
688
+ label: admin.inspector_fab_title,
689
+ keys: devInspectorShortcutLabel(),
690
+ ),
691
+ if (kIsWeb) ...[
692
+ const SettingsDivider(),
693
+ _ShortcutRow(
694
+ label: admin.device_preview_title,
695
+ keys: webDevicePreviewShortcutLabel(),
696
+ ),
697
+ ],
698
+ ],
699
+ ),
700
+ ),
701
+ ],
512
702
  const SizedBox(height: KasySpacing.md),
513
703
  Text(
514
704
  ov.users_hint,
@@ -571,6 +761,39 @@ class _InfoRow extends StatelessWidget {
571
761
  }
572
762
  }
573
763
 
764
+ /// A dev-tool name with its global keyboard shortcut on the right (read-only).
765
+ class _ShortcutRow extends StatelessWidget {
766
+ final String label;
767
+ final String keys;
768
+ const _ShortcutRow({required this.label, required this.keys});
769
+
770
+ @override
771
+ Widget build(BuildContext context) {
772
+ return Padding(
773
+ padding: const EdgeInsets.symmetric(vertical: KasySpacing.smd),
774
+ child: Row(
775
+ children: [
776
+ Expanded(
777
+ child: Text(
778
+ label,
779
+ style: context.textTheme.bodyMedium?.copyWith(
780
+ color: context.colors.onSurface,
781
+ ),
782
+ ),
783
+ ),
784
+ const SizedBox(width: KasySpacing.sm),
785
+ Text(
786
+ keys,
787
+ style: context.kasyTextTheme.rowValue.copyWith(
788
+ color: context.colors.muted,
789
+ ),
790
+ ),
791
+ ],
792
+ ),
793
+ );
794
+ }
795
+ }
796
+
574
797
  class _CopyButton extends StatelessWidget {
575
798
  final VoidCallback onTap;
576
799
  const _CopyButton({required this.onTap});
@@ -583,7 +806,7 @@ class _CopyButton extends StatelessWidget {
583
806
  child: Padding(
584
807
  padding: const EdgeInsets.all(6),
585
808
  child: Icon(
586
- Icons.content_copy_rounded,
809
+ KasyIcons.copy,
587
810
  size: 16,
588
811
  color: context.colors.primary,
589
812
  ),
@@ -655,7 +878,7 @@ class _UsersTab extends ConsumerWidget {
655
878
  final u = t.admin_console.users;
656
879
  if (!isAdmin) {
657
880
  return _EmptyState(
658
- icon: Icons.lock_outline_rounded,
881
+ icon: KasyIcons.security,
659
882
  title: u.title,
660
883
  message: t.admin_console.requires_admin,
661
884
  );
@@ -673,7 +896,7 @@ class _RequestsTab extends ConsumerWidget {
673
896
  final r = t.admin_console.requests;
674
897
  if (!isAdmin) {
675
898
  return _EmptyState(
676
- icon: Icons.lock_outline_rounded,
899
+ icon: KasyIcons.security,
677
900
  title: r.title,
678
901
  message: t.admin_console.requires_admin,
679
902
  );
@@ -968,262 +1191,212 @@ class _RequestEditorSheetState extends ConsumerState<_RequestEditorSheet> {
968
1191
  }
969
1192
 
970
1193
  // ─────────────────────────────────────────────────────────────────────────────
971
- // Kitfeature demos + the component gallery
1194
+ // Tools sub-screens each is a real console section (body only; the admin
1195
+ // shell supplies the chrome), reached from the sidebar's "Ferramentas" submenu.
972
1196
  // ─────────────────────────────────────────────────────────────────────────────
973
1197
 
974
- class _KitTab extends ConsumerWidget {
975
- const _KitTab();
976
-
977
- @override
978
- Widget build(BuildContext context, WidgetRef ref) {
979
- final page = t.home.features_page;
980
- final dash = t.home.dashboard;
981
- final groups = t.admin_console.groups;
1198
+ /// Settings-style card: rows separated by hairline dividers — the exact clean
1199
+ /// list pattern of the Settings screen (no busy card grid). Shared by the Tools
1200
+ /// sections.
1201
+ Widget _groupCard(List<Widget> rows) => _CardShell(
1202
+ child: Column(
1203
+ mainAxisSize: MainAxisSize.min,
1204
+ children: [
1205
+ for (int i = 0; i < rows.length; i++) ...[
1206
+ if (i > 0) const SettingsDivider(),
1207
+ rows[i],
1208
+ ],
1209
+ ],
1210
+ ),
1211
+ );
982
1212
 
983
- final List<Widget> features = <Widget>[
984
- if (withFeedback)
985
- _ActionCard(
986
- icon: KasyIcons.message,
987
- title: page.feedback_title,
988
- subtitle: page.feedback_description,
989
- onTap: () => context.push('/feedback'),
990
- ),
991
- _ActionCard(
992
- icon: KasyIcons.notification,
993
- title: page.notification_title,
994
- subtitle: page.notification_description,
995
- onTap: () {
996
- final settings = ref.read(notificationsSettingsProvider);
997
- final localNotifier = ref.read(localNotifierProvider);
998
- kasy_kit.Notification.withData(
999
- id: 'fake-id',
1000
- title: page.notification_demo_title,
1001
- body: page.notification_demo_body,
1002
- createdAt: DateTime.now(),
1003
- notifier: localNotifier,
1004
- notifierSettings: settings,
1005
- ).show();
1006
- },
1007
- ),
1008
- _ActionCard(
1009
- icon: KasyIcons.notificationActive,
1010
- title: page.send_push_title,
1011
- subtitle: page.send_push_description,
1012
- onTap: () => context.push(adminRouteSendPush),
1013
- ),
1014
- if (withRevenuecat)
1015
- _ActionCard(
1016
- icon: KasyIcons.payment,
1017
- title: page.paywall_title,
1018
- subtitle: page.paywall_description,
1019
- onTap: () => context.push('/premium'),
1020
- ),
1021
- ];
1213
+ /// Paywalls panel lists every paywall variant. Production section; the live
1214
+ /// preview route is debug-only, so a row previews only in debug builds.
1215
+ class _PaywallsTab extends StatelessWidget {
1216
+ const _PaywallsTab();
1022
1217
 
1218
+ @override
1219
+ Widget build(BuildContext context) {
1220
+ final admin = t.settings.admin;
1023
1221
  return _TabScroll(
1024
1222
  children: [
1025
- _GroupLabel(groups.features),
1026
- _ResponsiveGrid(minItemWidth: 280, maxCols: 2, children: features),
1027
- const SizedBox(height: KasySpacing.lg),
1028
- _GroupLabel(groups.showcase),
1029
- _ResponsiveGrid(
1030
- minItemWidth: 280,
1031
- maxCols: 2,
1032
- children: [
1033
- _ActionCard(
1034
- icon: KasyIcons.widgets,
1035
- title: dash.components_title,
1036
- subtitle: dash.components_subtitle,
1037
- tone: context.colors.success,
1038
- onTap: () => context.push('/components'),
1223
+ _GroupLabel(admin.paywalls),
1224
+ _groupCard([
1225
+ for (final paywall in PaywallFactory.values)
1226
+ SettingsTile(
1227
+ icon: KasyIcons.payment,
1228
+ title: paywall.name,
1229
+ onTap: () => context.push(adminRoutePremiumPreview(paywall.name)),
1039
1230
  ),
1040
- ],
1041
- ),
1231
+ ]),
1042
1232
  ],
1043
1233
  );
1044
1234
  }
1045
1235
  }
1046
1236
 
1047
- // ─────────────────────────────────────────────────────────────────────────────
1048
- // Tools every developer toggle/action migrated from the old admin sheet
1049
- // ─────────────────────────────────────────────────────────────────────────────
1237
+ /// The UI-kit catalog as a console section (debug only). Width-capped + centered
1238
+ /// to the same [_contentMaxWidth] as every other section (Paywalls, Overview…),
1239
+ /// so the console reads as one consistent column instead of the catalog
1240
+ /// stretching edge-to-edge on desktop.
1241
+ class _ComponentsTab extends StatelessWidget {
1242
+ const _ComponentsTab();
1243
+
1244
+ @override
1245
+ Widget build(BuildContext context) =>
1246
+ const _MaxWidth(child: HomeComponentsCatalog());
1247
+ }
1050
1248
 
1051
- class _ToolsTab extends ConsumerWidget {
1052
- const _ToolsTab();
1249
+ /// Developer tooling (debug only): identity helpers, debug actions and a
1250
+ /// local-notification test — the leftovers that aren't worth a screen of their
1251
+ /// own, grouped as clean Settings-style lists.
1252
+ class _DebugTab extends ConsumerWidget {
1253
+ const _DebugTab();
1053
1254
 
1054
1255
  @override
1055
1256
  Widget build(BuildContext context, WidgetRef ref) {
1056
- final userState = ref.watch(userStateNotifierProvider);
1257
+ final page = t.home.features_page;
1057
1258
  final admin = t.settings.admin;
1058
1259
  final groups = t.admin_console.groups;
1059
-
1060
- final List<Widget> previews = <Widget>[
1061
- ValueListenableBuilder<bool>(
1062
- valueListenable: devInspectorEnabledNotifier,
1063
- builder: (context, enabled, _) {
1064
- return SettingsSwitchTile(
1065
- icon: KasyIcons.widgets,
1066
- title: admin.inspector_fab_title,
1067
- subtitle:
1068
- '${admin.inspector_fab_subtitle_prefix} ${devInspectorShortcutLabel()}',
1069
- value: enabled,
1070
- onChanged: (v) async {
1071
- final p = await SharedPreferences.getInstance();
1072
- await p.setBool(devInspectorEnabledPrefKey, v);
1073
- devInspectorEnabledNotifier.value = v;
1074
- if (v && context.mounted) _backToApp(context);
1075
- },
1076
- );
1077
- },
1078
- ),
1079
- if (kIsWeb)
1080
- ValueListenableBuilder<bool>(
1081
- valueListenable: webDevicePreviewEnabledNotifier,
1082
- builder: (context, enabled, _) {
1083
- return SettingsSwitchTile(
1084
- icon: KasyIcons.phoneAndroid,
1085
- title: admin.device_preview_title,
1086
- subtitle:
1087
- '${admin.inspector_fab_subtitle_prefix} ${webDevicePreviewShortcutLabel()}',
1088
- value: enabled,
1089
- onChanged: (v) async {
1090
- final p = await SharedPreferences.getInstance();
1091
- await p.setBool(webDevicePreviewEnabledPrefKey, v);
1092
- webDevicePreviewEnabledNotifier.value = v;
1093
- if (v && context.mounted) _backToApp(context);
1094
- },
1095
- );
1096
- },
1097
- ),
1098
- ];
1099
-
1100
- final List<Widget> tools = <Widget>[
1101
- _ActionCard(
1102
- icon: KasyIcons.note,
1103
- title: admin.update_bottom_sheet,
1104
- // Preview the sheet over the current screen; dismissing (tap-outside /
1105
- // Continue) just closes it and returns here, without navigating away.
1106
- onTap: () => showUpdateBottomSheet(
1107
- context: navigatorKey.currentContext!,
1108
- version: '0.0.0',
1109
- ),
1110
- ),
1111
- _ActionCard(
1112
- icon: KasyIcons.download,
1113
- title: admin.preview_update_available,
1114
- // Previews the optional (dismissible) sheet so the design can be
1115
- // reviewed without TestFlight or Remote Config. The forced variant is
1116
- // the same layout, blocking — test it via app_min_version.
1117
- onTap: () => showUpdateAvailableSheet(
1118
- navigatorKey.currentContext!,
1119
- forced: false,
1120
- ),
1121
- ),
1122
- _ActionCard(
1123
- icon: KasyIcons.payment,
1124
- title: admin.paywalls,
1125
- onTap: () => context.push(adminRoutePaywalls),
1126
- ),
1127
- _ActionCard(
1128
- icon: KasyIcons.check,
1129
- title: admin.test_onboarding,
1130
- onTap: () => ref.read(goRouterProvider).go('/onboarding'),
1131
- ),
1132
- _ActionCard(
1133
- icon: KasyIcons.notificationActive,
1134
- title: admin.send_push_title,
1135
- onTap: () => context.push(adminRouteSendPush),
1136
- ),
1137
- _ActionCard(
1138
- icon: KasyIcons.star,
1139
- title: admin.ask_review,
1140
- // Has a design (the review dialog), so it's previewable on web too —
1141
- // only the store action no-ops there.
1142
- onTap: () => showReviewDialog(context, ref, force: true),
1143
- ),
1144
- _ActionCard(
1145
- icon: KasyIcons.message,
1146
- title: admin.home_widgets_panel,
1147
- onTap: () => context.push(adminRouteHomeWidgets),
1148
- ),
1149
- ];
1150
-
1151
- final List<Widget> identity = <Widget>[
1152
- _ActionCard(
1153
- icon: KasyIcons.person,
1154
- title: admin.copy_user_id,
1155
- onTap: () {
1156
- Clipboard.setData(
1157
- ClipboardData(text: userState.user.idOrNull ?? 'no-id (guest)'),
1158
- );
1159
- ref.read(toastProvider).alert(
1160
- title: t.common.copied,
1161
- text: admin.user_id_copied,
1162
- );
1163
- },
1164
- ),
1165
- _ActionCard(
1166
- icon: KasyIcons.notification,
1167
- title: admin.copy_fcm_token,
1168
- onTap: () async {
1169
- if (kIsWeb) {
1170
- ref.read(toastProvider).alert(
1171
- title: t.common.native_only_title,
1172
- text: admin.native_only,
1173
- );
1174
- return;
1175
- }
1176
- final token = await FirebaseMessaging.instance.getToken();
1177
- if (token == null) {
1178
- ref.read(toastProvider).alert(
1179
- title: t.common.unavailable,
1180
- text: admin.fcm_token_unavailable,
1181
- );
1182
- return;
1183
- }
1184
- await Clipboard.setData(ClipboardData(text: token));
1185
- ref.read(toastProvider).alert(
1186
- title: t.common.copied,
1187
- text: admin.fcm_token_copied,
1188
- );
1189
- },
1190
- ),
1191
- _ActionCard(
1192
- icon: KasyIcons.notificationActive,
1193
- title: admin.ask_notification,
1194
- onTap: () {
1195
- if (kIsWeb) {
1196
- ref.read(toastProvider).alert(
1197
- title: t.common.native_only_title,
1198
- text: admin.native_only,
1199
- );
1200
- return;
1201
- }
1202
- ref.read(notificationsSettingsProvider).askPermission();
1203
- },
1204
- ),
1205
- ];
1260
+ final userState = ref.watch(userStateNotifierProvider);
1206
1261
 
1207
1262
  return _TabScroll(
1208
1263
  children: [
1209
- _GroupLabel(groups.preview),
1210
- _CardShell(
1211
- child: Column(
1212
- mainAxisSize: MainAxisSize.min,
1213
- children: [
1214
- for (int i = 0; i < previews.length; i++) ...[
1215
- if (i > 0) const SettingsDivider(),
1216
- previews[i],
1217
- ],
1218
- ],
1264
+ // ── Identity ────────────────────────────────────────────────────────
1265
+ _GroupLabel(groups.identity),
1266
+ _groupCard([
1267
+ SettingsTile(
1268
+ icon: KasyIcons.person,
1269
+ title: admin.copy_user_id,
1270
+ onTap: () {
1271
+ Clipboard.setData(
1272
+ ClipboardData(
1273
+ text: userState.user.idOrNull ?? 'no-id (guest)',
1274
+ ),
1275
+ );
1276
+ ref.read(toastProvider).alert(
1277
+ title: t.common.copied,
1278
+ text: admin.user_id_copied,
1279
+ );
1280
+ },
1219
1281
  ),
1220
- ),
1282
+ SettingsTile(
1283
+ icon: KasyIcons.notification,
1284
+ title: admin.copy_fcm_token,
1285
+ onTap: () async {
1286
+ if (kIsWeb) {
1287
+ ref.read(toastProvider).alert(
1288
+ title: t.common.native_only_title,
1289
+ text: admin.native_only,
1290
+ );
1291
+ return;
1292
+ }
1293
+ final token = await FirebaseMessaging.instance.getToken();
1294
+ if (token == null) {
1295
+ ref.read(toastProvider).alert(
1296
+ title: t.common.unavailable,
1297
+ text: admin.fcm_token_unavailable,
1298
+ );
1299
+ return;
1300
+ }
1301
+ await Clipboard.setData(ClipboardData(text: token));
1302
+ ref.read(toastProvider).alert(
1303
+ title: t.common.copied,
1304
+ text: admin.fcm_token_copied,
1305
+ );
1306
+ },
1307
+ ),
1308
+ SettingsTile(
1309
+ icon: KasyIcons.notificationActive,
1310
+ title: admin.ask_notification,
1311
+ onTap: () {
1312
+ if (kIsWeb) {
1313
+ ref.read(toastProvider).alert(
1314
+ title: t.common.native_only_title,
1315
+ text: admin.native_only,
1316
+ );
1317
+ return;
1318
+ }
1319
+ ref.read(notificationsSettingsProvider).askPermission();
1320
+ },
1321
+ ),
1322
+ ]),
1323
+
1221
1324
  const SizedBox(height: KasySpacing.lg),
1325
+
1326
+ // ── Debug actions ───────────────────────────────────────────────────
1222
1327
  _GroupLabel(groups.debug_actions),
1223
- _ResponsiveGrid(minItemWidth: 280, maxCols: 2, children: tools),
1328
+ _groupCard([
1329
+ SettingsTile(
1330
+ icon: KasyIcons.note,
1331
+ title: admin.update_bottom_sheet,
1332
+ // Preview the sheet over the current screen; dismissing just closes
1333
+ // it and returns here, without navigating away.
1334
+ onTap: () => showUpdateBottomSheet(
1335
+ context: navigatorKey.currentContext!,
1336
+ version: '0.0.0',
1337
+ ),
1338
+ ),
1339
+ SettingsTile(
1340
+ icon: KasyIcons.download,
1341
+ title: admin.preview_update_available,
1342
+ // Previews the optional (dismissible) sheet. The forced variant is
1343
+ // the same layout, blocking — test it via app_min_version.
1344
+ onTap: () => showUpdateAvailableSheet(
1345
+ navigatorKey.currentContext!,
1346
+ forced: false,
1347
+ ),
1348
+ ),
1349
+ SettingsTile(
1350
+ icon: KasyIcons.check,
1351
+ title: admin.test_onboarding,
1352
+ onTap: () => ref.read(goRouterProvider).go('/onboarding'),
1353
+ ),
1354
+ SettingsTile(
1355
+ icon: KasyIcons.star,
1356
+ title: admin.ask_review,
1357
+ // Has a design (the review dialog), previewable on web too — only
1358
+ // the store action no-ops there.
1359
+ onTap: () => showReviewDialog(context, ref, force: true),
1360
+ ),
1361
+ SettingsTile(
1362
+ icon: KasyIcons.message,
1363
+ title: admin.home_widgets_panel,
1364
+ // Pushed full-screen (its own back button), a drill-down from here.
1365
+ onTap: () => context.push(adminRouteHomeWidgets),
1366
+ ),
1367
+ ]),
1368
+
1224
1369
  const SizedBox(height: KasySpacing.lg),
1225
- _GroupLabel(groups.identity),
1226
- _ResponsiveGrid(minItemWidth: 280, maxCols: 2, children: identity),
1370
+
1371
+ // ── Notification test ───────────────────────────────────────────────
1372
+ _GroupLabel(groups.notification_test),
1373
+ _groupCard([
1374
+ SettingsTile(
1375
+ icon: KasyIcons.notification,
1376
+ title: page.notification_title,
1377
+ onTap: () {
1378
+ // Local notifications don't fire on web — tell the user instead
1379
+ // of doing nothing when tapped.
1380
+ if (kIsWeb) {
1381
+ ref.read(toastProvider).alert(
1382
+ title: t.common.native_only_title,
1383
+ text: admin.native_only,
1384
+ );
1385
+ return;
1386
+ }
1387
+ final settings = ref.read(notificationsSettingsProvider);
1388
+ final localNotifier = ref.read(localNotifierProvider);
1389
+ kasy_kit.Notification.withData(
1390
+ id: 'fake-id',
1391
+ title: page.notification_demo_title,
1392
+ body: page.notification_demo_body,
1393
+ createdAt: DateTime.now(),
1394
+ notifier: localNotifier,
1395
+ notifierSettings: settings,
1396
+ ).show();
1397
+ },
1398
+ ),
1399
+ ]),
1227
1400
  ],
1228
1401
  );
1229
1402
  }