kasy-cli 1.37.1 → 1.39.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 (120) 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/core/data/api/user_api.dart +18 -0
  4. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
  5. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  6. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  7. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  8. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  10. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  11. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  12. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  13. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  15. package/lib/scaffold/shared/generator-utils.js +12 -6
  16. package/package.json +1 -1
  17. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  18. package/templates/firebase/AGENTS.md +7 -1
  19. package/templates/firebase/DESIGN_SYSTEM.md +35 -8
  20. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  21. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  22. package/templates/firebase/assets/icons/facebook.svg +49 -0
  23. package/templates/firebase/assets/icons/google.svg +1 -0
  24. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  25. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  26. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  27. package/templates/firebase/lib/components/components.dart +1 -1
  28. package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
  29. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  30. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  32. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
  34. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  35. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  36. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
  37. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  38. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
  39. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  40. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  41. package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
  42. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  43. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  44. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  45. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  46. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  47. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
  48. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  49. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  50. package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
  51. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  52. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  53. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  54. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  55. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  56. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  57. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  58. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
  59. package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
  60. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  61. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  62. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  63. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  66. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  67. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  68. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  69. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  70. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  71. package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
  72. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
  73. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  74. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  75. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  76. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
  77. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  78. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
  79. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  80. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  81. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  82. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  83. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  84. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  85. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  86. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  87. package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
  88. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
  89. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  90. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  91. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  92. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
  93. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
  94. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  95. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
  96. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  97. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  98. package/templates/firebase/lib/i18n/en.i18n.json +749 -698
  99. package/templates/firebase/lib/i18n/es.i18n.json +749 -698
  100. package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
  101. package/templates/firebase/lib/main.dart +20 -7
  102. package/templates/firebase/lib/router.dart +70 -46
  103. package/templates/firebase/pubspec.yaml +2 -1
  104. package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
  105. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  106. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  107. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  108. package/templates/firebase/tool/design_check.dart +9 -0
  109. package/templates/firebase/assets/icons/apple.png +0 -0
  110. package/templates/firebase/assets/icons/facebook.png +0 -0
  111. package/templates/firebase/assets/icons/google.png +0 -0
  112. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  113. package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
  114. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  115. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  116. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  117. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  118. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
  119. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
  120. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -7,11 +7,13 @@ 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';
11
+ import 'package:kasy_kit/components/kasy_skeleton.dart';
10
12
  import 'package:kasy_kit/components/kasy_status_tag.dart';
11
- import 'package:kasy_kit/components/kasy_tabs.dart';
12
13
  import 'package:kasy_kit/components/kasy_text_field.dart';
13
14
  import 'package:kasy_kit/core/app_update/update_available_sheet.dart';
14
- import 'package:kasy_kit/core/config/features.dart';
15
+ import 'package:kasy_kit/core/bottom_menu/sidebar_focus.dart';
16
+ import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
15
17
  import 'package:kasy_kit/core/data/models/user.dart';
16
18
  import 'package:kasy_kit/core/dev_inspector/dev_inspector.dart';
17
19
  import 'package:kasy_kit/core/rating/widgets/review_popup.dart';
@@ -19,46 +21,175 @@ import 'package:kasy_kit/core/states/user_state_notifier.dart';
19
21
  import 'package:kasy_kit/core/theme/theme.dart';
20
22
  import 'package:kasy_kit/core/toast/toast_service.dart';
21
23
  import 'package:kasy_kit/core/web_device_preview/web_device_preview.dart';
24
+ import 'package:kasy_kit/core/widgets/kasy_hover.dart';
25
+ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
22
26
  import 'package:kasy_kit/core/widgets/update_bottom_sheet.dart';
23
27
  import 'package:kasy_kit/features/feedbacks/api/entities/feature_request_entity.dart';
24
28
  import 'package:kasy_kit/features/feedbacks/api/feature_request_api.dart';
29
+ import 'package:kasy_kit/features/home/home_components_page.dart';
25
30
  import 'package:kasy_kit/features/notifications/api/local_notifier.dart';
26
31
  import 'package:kasy_kit/features/notifications/providers/models/notification.dart'
27
32
  as kasy_kit;
28
33
  import 'package:kasy_kit/features/settings/ui/components/admin/admin_routes.dart';
34
+ import 'package:kasy_kit/features/settings/ui/components/admin/admin_users_api.dart';
29
35
  import 'package:kasy_kit/features/settings/ui/components/admin/admin_users_tab.dart';
36
+ import 'package:kasy_kit/features/settings/ui/components/admin/send_push_notification_page.dart';
37
+ import 'package:kasy_kit/features/settings/ui/widgets/kasy_user_avatar.dart';
30
38
  import 'package:kasy_kit/features/settings/ui/widgets/settings_tile.dart';
39
+ import 'package:kasy_kit/features/subscriptions/ui/component/premium_page_factory.dart';
31
40
  import 'package:kasy_kit/i18n/translations.g.dart';
32
41
  import 'package:kasy_kit/router.dart';
33
42
  import 'package:package_info_plus/package_info_plus.dart';
34
- import 'package:shared_preferences/shared_preferences.dart';
35
43
 
36
- /// Full-screen developer admin console (debug only).
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+ // Admin sections — single source of truth shared by the router (URL branches)
46
+ // and the sidebar (nav items), so the two can never drift out of sync.
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ /// A navigable admin section. Each maps to a real URL branch in the router.
50
+ ///
51
+ /// The first three are top-level rows under the "ADMIN" label; the rest live
52
+ /// inside the sidebar's expandable "Ferramentas" submenu
53
+ /// ([AdminSectionDef.inToolsGroup]). All sections ship in production (admins
54
+ /// reach them in release); only [debug]'s home-widgets-panel tile is hidden
55
+ /// outside [kDebugMode].
56
+ enum AdminSection {
57
+ overview,
58
+ users,
59
+ requests,
60
+ sendPush,
61
+ paywalls,
62
+ components,
63
+ debug,
64
+ }
65
+
66
+ /// Descriptor for one admin section: its real URL [path], sidebar [icon] and the
67
+ /// [build]er for its (body-only) view. [inToolsGroup] places the row inside the
68
+ /// sidebar's "Ferramentas" submenu instead of the top-level list.
69
+ class AdminSectionDef {
70
+ final AdminSection id;
71
+ final String path;
72
+ final IconData icon;
73
+ final Widget Function() build;
74
+ final bool inToolsGroup;
75
+ const AdminSectionDef({
76
+ required this.id,
77
+ required this.path,
78
+ required this.icon,
79
+ required this.build,
80
+ this.inToolsGroup = false,
81
+ });
82
+ }
83
+
84
+ /// Base URL of the admin console. Every section is this or a child of it, so the
85
+ /// router's security guard can match the whole area with a single prefix.
86
+ const String adminBasePath = '/admin';
87
+
88
+ /// The admin sections, in sidebar/URL order. The four "Ferramentas" sub-screens
89
+ /// are real sections too (own branch, own URL, persistent rail) and all ship in
90
+ /// production — only Debug's home-widgets-panel tile is hidden outside debug
91
+ /// builds. The router (branches) and the sidebar (nav rows) both read this
92
+ /// single list, so they can never drift.
93
+ List<AdminSectionDef> adminSections() => [
94
+ AdminSectionDef(
95
+ id: AdminSection.overview,
96
+ path: adminBasePath,
97
+ icon: KasyIcons.dashboard,
98
+ build: () => const _OverviewTab(),
99
+ ),
100
+ AdminSectionDef(
101
+ id: AdminSection.users,
102
+ path: '$adminBasePath/users',
103
+ icon: KasyIcons.users,
104
+ build: () => const _UsersTab(),
105
+ ),
106
+ AdminSectionDef(
107
+ id: AdminSection.requests,
108
+ path: '$adminBasePath/requests',
109
+ icon: KasyIcons.idea,
110
+ build: () => const _RequestsTab(),
111
+ ),
112
+ // ── "Ferramentas" submenu ───────────────────────────────────────────────
113
+ AdminSectionDef(
114
+ id: AdminSection.sendPush,
115
+ path: adminRouteSendPush,
116
+ icon: KasyIcons.notificationActive,
117
+ build: () => const SendPushNotificationPage(),
118
+ inToolsGroup: true,
119
+ ),
120
+ AdminSectionDef(
121
+ id: AdminSection.paywalls,
122
+ path: adminRoutePaywalls,
123
+ icon: KasyIcons.payment,
124
+ build: () => const _PaywallsTab(),
125
+ inToolsGroup: true,
126
+ ),
127
+ // Components and Debug ship in production too (admins reach them in release).
128
+ // Debug's body hides its one developer-only tile (the home-widgets panel,
129
+ // whose drill-down route registers only in kDebugMode).
130
+ AdminSectionDef(
131
+ id: AdminSection.components,
132
+ path: adminRouteComponents,
133
+ icon: KasyIcons.widgets,
134
+ build: () => const _ComponentsTab(),
135
+ inToolsGroup: true,
136
+ ),
137
+ AdminSectionDef(
138
+ id: AdminSection.debug,
139
+ path: adminRouteDebug,
140
+ icon: KasyIcons.note,
141
+ build: () => const _DebugTab(),
142
+ inToolsGroup: true,
143
+ ),
144
+ ];
145
+
146
+ /// Localized sidebar / app-bar label for a section.
147
+ String adminSectionLabel(AdminSection id) {
148
+ final tabs = t.admin_console.tabs;
149
+ return switch (id) {
150
+ AdminSection.overview => tabs.overview,
151
+ AdminSection.users => tabs.users,
152
+ AdminSection.requests => tabs.requests,
153
+ AdminSection.sendPush => t.home.features_page.send_push_title,
154
+ AdminSection.paywalls => t.settings.admin.paywalls,
155
+ AdminSection.components => t.home.dashboard.components_title,
156
+ AdminSection.debug => tabs.debug,
157
+ };
158
+ }
159
+
160
+ /// Persistent chrome for the admin console: the navigation rail (sidebar on
161
+ /// desktop, drawer on mobile) wrapping the routed section content. Each section
162
+ /// is a real, URL-addressable screen ([adminSections]); the rail reflects and
163
+ /// drives [StatefulNavigationShell.currentIndex] so the browser URL, the back
164
+ /// button and web state-restoration all behave like the rest of the app.
37
165
  ///
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.
166
+ /// Reached by `context.push('/admin')` (double-tap on the settings version
167
+ /// label); popping returns to the app. Built from the same design system as the
168
+ /// real [KasySidebar]/[KasyAppBar] so it reads as native product chrome.
43
169
  ///
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});
170
+ /// Access: the router redirects non-admins away from `/admin*` in production
171
+ /// (see the guard in `router.dart`) — the real lock is at the routing layer, not
172
+ /// just hidden UI. In debug anyone reaches it; server-backed sections still gate
173
+ /// their data by role internally.
174
+ class AdminShell extends ConsumerStatefulWidget {
175
+ final StatefulNavigationShell navigationShell;
176
+ const AdminShell({super.key, required this.navigationShell});
48
177
 
49
178
  @override
50
- ConsumerState<AdminPage> createState() => _AdminPageState();
179
+ ConsumerState<AdminShell> createState() => _AdminShellState();
51
180
  }
52
181
 
53
- class _AdminPageState extends ConsumerState<AdminPage> {
54
- int _tab = 0;
182
+ class _AdminShellState extends ConsumerState<AdminShell> {
183
+ final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
55
184
 
56
185
  @override
57
186
  Widget build(BuildContext context) {
58
187
  final bool isAdmin = ref.watch(userStateNotifierProvider).user.isAdmin;
59
188
  final ac = t.admin_console;
60
189
 
61
- // The console itself opens for admins (even in release) or anyone in debug.
190
+ // Defense in depth: the router already redirects non-admins away from
191
+ // /admin* in production, so this only ever renders in the unexpected case
192
+ // the shell is reached without the role (never in a release build).
62
193
  if (!isAdmin && !kDebugMode) {
63
194
  return Scaffold(
64
195
  backgroundColor: context.colors.background,
@@ -68,7 +199,7 @@ class _AdminPageState extends ConsumerState<AdminPage> {
68
199
  const KasyAppBar(title: 'Admin'),
69
200
  Expanded(
70
201
  child: _EmptyState(
71
- icon: Icons.lock_outline_rounded,
202
+ icon: KasyIcons.security,
72
203
  title: ac.tabs.overview,
73
204
  message: ac.requires_admin,
74
205
  ),
@@ -78,43 +209,156 @@ class _AdminPageState extends ConsumerState<AdminPage> {
78
209
  );
79
210
  }
80
211
 
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);
212
+ final List<AdminSectionDef> sections = adminSections();
213
+ final int index = widget.navigationShell.currentIndex;
214
+ final double width = MediaQuery.sizeOf(context).width;
215
+ // Same breakpoints as the app shell (bottom_menu.dart +
216
+ // web_content_wrapper.dart): the sidebar shows from tablet up (collapsing to
217
+ // the icon rail on tablet), and the web header replaces the app bar only on
218
+ // desktop.
219
+ final bool hasSidebar = width >= DeviceType.medium.breakpoint;
220
+
221
+ void selectEntry(int i) {
222
+ // Re-tapping the active section resets its branch to the root, the
223
+ // standard StatefulShellRoute / tab-bar behaviour.
224
+ widget.navigationShell.goBranch(
225
+ i,
226
+ initialLocation: i == widget.navigationShell.currentIndex,
227
+ );
228
+ if (!hasSidebar) _scaffoldKey.currentState?.closeDrawer();
229
+ }
230
+
231
+ // Profile block in the rail, populated exactly like the app sidebar
232
+ // (bottom_menu.dart): the real name/email + the signed-in user's avatar.
233
+ final User user = ref.watch(userStateNotifierProvider).user;
234
+ final (String profileName, String profileEmail) = switch (user) {
235
+ final AuthenticatedUserData u => (
236
+ (u.name?.isNotEmpty ?? false) ? u.name! : u.email.split('@').first,
237
+ u.email,
238
+ ),
239
+ _ => (t.settings.my_account, ''),
240
+ };
241
+
242
+ // The app's real KasySidebar, driven by the admin sections (same component,
243
+ // same logo/dividers/collapse/tooltips/profile — no bespoke copy). The
244
+ // top-level sections are flat rows; the four Tools sub-screens live inside an
245
+ // expandable "Ferramentas" submenu (the same dropdown recipe as the app's
246
+ // Income menu), so each keeps the rail and its own URL.
247
+ KasySidebar buildRail({required bool isDrawer}) {
248
+ final List<KasySidebarItem> items = [
249
+ for (int i = 0; i < sections.length; i++)
250
+ if (!sections[i].inToolsGroup)
251
+ KasySidebarItem(
252
+ icon: sections[i].icon,
253
+ label: adminSectionLabel(sections[i].id),
254
+ selected: i == index,
255
+ onTap: () => selectEntry(i),
256
+ ),
257
+ ];
258
+ final List<KasySidebarSubItem> toolsChildren = [
259
+ for (int i = 0; i < sections.length; i++)
260
+ if (sections[i].inToolsGroup)
261
+ KasySidebarSubItem(
262
+ label: adminSectionLabel(sections[i].id),
263
+ selected: i == index,
264
+ onTap: () => selectEntry(i),
265
+ ),
266
+ ];
267
+ if (toolsChildren.isNotEmpty) {
268
+ items.add(
269
+ KasySidebarItem(
270
+ icon: KasyIcons.briefcase,
271
+ label: t.admin_console.tabs.tools,
272
+ children: toolsChildren,
273
+ ),
274
+ );
275
+ }
276
+ return KasySidebar(
277
+ isDrawer: isDrawer,
278
+ items: items,
279
+ sectionLabel: 'ADMIN',
280
+ footerItems: [
281
+ KasySidebarItem(
282
+ icon: KasyIcons.arrowBackIos,
283
+ label: ac.back_to_app,
284
+ onTap: () {
285
+ if (!hasSidebar) _scaffoldKey.currentState?.closeDrawer();
286
+ _backToApp(context);
287
+ },
288
+ ),
289
+ ],
290
+ profileName: profileName,
291
+ profileEmail: profileEmail,
292
+ profileAvatar: const KasyUserAvatar(),
293
+ );
294
+ }
91
295
 
296
+ final Widget content = widget.navigationShell;
297
+
298
+ // Tablet + desktop: the real KasySidebar persists and EVERY section gets the
299
+ // same chrome as the rest of the app (bottom_menu.dart): the web header on
300
+ // desktop / the rootTab app bar on tablet, both supplied here so the section
301
+ // bodies stay chrome-free. Keyboard order matches the app shell:
302
+ // OrderedTraversalPolicy flows Tab sidebar -> header -> content;
303
+ // KasyFocusableSidebar anchors the initial focus to the rail and hosts the
304
+ // skip-to-content link; WebContentWrapper carries the desktop web header +
305
+ // the content focus target (orders 2 and 3) exactly like every app page.
306
+ if (hasSidebar) {
307
+ final Widget right = WebContentWrapper(
308
+ child: Column(
309
+ crossAxisAlignment: CrossAxisAlignment.stretch,
310
+ children: [
311
+ KasyAppBar(
312
+ title: adminSectionLabel(sections[index].id),
313
+ style: KasyAppBarStyle.rootTab,
314
+ onThemeToggle: () => ThemeProvider.of(context).toggle(),
315
+ ),
316
+ Expanded(child: content),
317
+ ],
318
+ ),
319
+ );
320
+ return Scaffold(
321
+ backgroundColor: context.colors.background,
322
+ body: FocusTraversalGroup(
323
+ policy: OrderedTraversalPolicy(),
324
+ child: Row(
325
+ crossAxisAlignment: CrossAxisAlignment.stretch,
326
+ children: [
327
+ FocusTraversalOrder(
328
+ order: const NumericFocusOrder(1),
329
+ child: KasyFocusableSidebar(child: buildRail(isDrawer: false)),
330
+ ),
331
+ Expanded(child: right),
332
+ ],
333
+ ),
334
+ ),
335
+ );
336
+ }
337
+
338
+ // Phone: the page app bar (with a menu orb) over a drawer holding the same
339
+ // KasySidebar — the standard mobile pattern.
92
340
  return Scaffold(
341
+ key: _scaffoldKey,
93
342
  backgroundColor: context.colors.background,
343
+ // Square edge / flat / surface fill all come from the global DrawerThemeData
344
+ // (core/theme/universal_theme.dart); here we only size it to the rail.
345
+ drawer: Drawer(width: kasySidebarWidth, child: buildRail(isDrawer: true)),
94
346
  body: Column(
95
347
  crossAxisAlignment: CrossAxisAlignment.stretch,
96
348
  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,
349
+ KasyAppBar(
350
+ title: adminSectionLabel(sections[index].id),
351
+ leading: Builder(
352
+ builder: (ctx) => KasyChromeOrbIconButton(
353
+ icon: KasyIcons.menu,
354
+ iconSize: KasyIconSize.md,
355
+ foregroundColor: context.colors.onSurface,
356
+ onPressed: () => Scaffold.of(ctx).openDrawer(),
357
+ tooltip: MaterialLocalizations.of(ctx).openAppDrawerTooltip,
109
358
  ),
110
359
  ),
111
360
  ),
112
- Expanded(
113
- child: IndexedStack(
114
- index: tab,
115
- children: [for (final e in entries) e.view],
116
- ),
117
- ),
361
+ Expanded(child: content),
118
362
  ],
119
363
  ),
120
364
  );
@@ -125,8 +369,18 @@ class _AdminPageState extends ConsumerState<AdminPage> {
125
369
  /// the Requests tab and the Overview count. Invalidate to refresh after a change.
126
370
  final _adminRequestsProvider =
127
371
  FutureProvider.autoDispose<List<FeatureRequestEntity>>((ref) {
128
- return ref.read(featureRequestApiProvider).getAll();
129
- });
372
+ return ref.read(featureRequestApiProvider).getAll();
373
+ });
374
+
375
+ /// Bounded user set powering the Overview metrics + sign-up chart (admins only).
376
+ /// Same source as the Users tab. [ref.keepAlive] caches the (slow) server fetch
377
+ /// for the session, so the first open waits on the listUsers function but
378
+ /// switching back to the Overview afterwards is instant.
379
+ final _adminUsersOverviewProvider =
380
+ FutureProvider.autoDispose<AdminUsersResult>((ref) {
381
+ ref.keepAlive();
382
+ return ref.read(adminUsersApiProvider).fetch();
383
+ });
130
384
 
131
385
  // ─────────────────────────────────────────────────────────────────────────────
132
386
  // Layout primitives
@@ -230,7 +484,8 @@ class _CardShell extends StatelessWidget {
230
484
  }
231
485
  }
232
486
 
233
- /// Soft-tinted rounded-square icon container.
487
+ /// Soft-tinted rounded-square icon container with a subtle diagonal gradient
488
+ /// and a hairline tint — reads a touch more polished than a flat fill.
234
489
  class _IconBubble extends StatelessWidget {
235
490
  final IconData icon;
236
491
  final Color tone;
@@ -239,27 +494,36 @@ class _IconBubble extends StatelessWidget {
239
494
 
240
495
  @override
241
496
  Widget build(BuildContext context) {
497
+ final double a = context.isDark ? 0.26 : 0.15;
242
498
  return Container(
243
499
  width: size,
244
500
  height: size,
245
501
  decoration: BoxDecoration(
246
- color: tone.withValues(alpha: context.isDark ? 0.22 : 0.12),
502
+ gradient: LinearGradient(
503
+ begin: Alignment.topLeft,
504
+ end: Alignment.bottomRight,
505
+ colors: [
506
+ tone.withValues(alpha: a + 0.06),
507
+ tone.withValues(alpha: (a - 0.06).clamp(0.0, 1.0)),
508
+ ],
509
+ ),
247
510
  borderRadius: BorderRadius.circular(size * 0.3),
511
+ border: Border.all(color: tone.withValues(alpha: 0.18)),
248
512
  ),
249
513
  child: Icon(icon, size: size * 0.5, color: tone),
250
514
  );
251
515
  }
252
516
  }
253
517
 
254
- /// Metric card: an icon bubble, a big value, and a muted label.
518
+ /// Metric card: a discreet icon, a big value and a muted label on a neutral
519
+ /// surface. Monochrome on purpose — the number is the focus, not the colour, so
520
+ /// the four sit quietly side by side instead of turning into a carnival.
255
521
  class _StatCard extends StatelessWidget {
256
522
  final IconData icon;
257
- final Color tone;
258
523
  final String value;
259
524
  final String label;
260
525
  const _StatCard({
261
526
  required this.icon,
262
- required this.tone,
263
527
  required this.value,
264
528
  required this.label,
265
529
  });
@@ -271,7 +535,7 @@ class _StatCard extends StatelessWidget {
271
535
  crossAxisAlignment: CrossAxisAlignment.start,
272
536
  mainAxisSize: MainAxisSize.min,
273
537
  children: [
274
- _IconBubble(icon: icon, tone: tone),
538
+ Icon(icon, size: 22, color: context.colors.muted),
275
539
  const SizedBox(height: KasySpacing.smd),
276
540
  Text(
277
541
  value,
@@ -296,118 +560,27 @@ class _StatCard extends StatelessWidget {
296
560
  }
297
561
  }
298
562
 
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
563
  /// 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.
564
+ /// four. Collapses to a single full-width column on narrow screens.
389
565
  class _ResponsiveGrid extends StatelessWidget {
390
566
  final List<Widget> children;
391
567
  final double minItemWidth;
392
- final int maxCols;
393
- const _ResponsiveGrid({
394
- required this.children,
395
- this.minItemWidth = 240,
396
- this.maxCols = 4,
397
- });
568
+ const _ResponsiveGrid({required this.children, this.minItemWidth = 240});
398
569
 
399
570
  @override
400
571
  Widget build(BuildContext context) {
401
572
  if (children.isEmpty) return const SizedBox.shrink();
402
573
  const double gap = KasySpacing.md;
574
+ const int maxCols = 4;
403
575
  return LayoutBuilder(
404
576
  builder: (context, constraints) {
405
577
  final double maxW = constraints.maxWidth;
406
578
  int cols = ((maxW + gap) / (minItemWidth + gap)).floor();
407
579
  cols = cols.clamp(1, maxCols);
408
580
  if (cols > children.length) cols = children.length;
409
- final double itemW =
410
- cols <= 1 ? maxW : (maxW - gap * (cols - 1)) / cols;
581
+ final double itemW = cols <= 1
582
+ ? maxW
583
+ : (maxW - gap * (cols - 1)) / cols;
411
584
  return Wrap(
412
585
  spacing: gap,
413
586
  runSpacing: gap,
@@ -420,10 +593,18 @@ class _ResponsiveGrid extends StatelessWidget {
420
593
  }
421
594
  }
422
595
 
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.
596
+ /// Leaves the admin console entirely, back to the app. Pops the ROOT navigator
597
+ /// so the whole admin shell is removed at once even when a detail screen is
598
+ /// open in a section's nested navigator (a plain pop would only close the
599
+ /// detail). Falls back to going home when it can't pop (e.g. a web reload / deep
600
+ /// link that landed directly on an /admin URL).
425
601
  void _backToApp(BuildContext context) {
426
- if (context.canPop()) context.pop();
602
+ final NavigatorState root = Navigator.of(context, rootNavigator: true);
603
+ if (root.canPop()) {
604
+ root.pop();
605
+ } else {
606
+ context.go('/');
607
+ }
427
608
  }
428
609
 
429
610
  // ─────────────────────────────────────────────────────────────────────────────
@@ -440,35 +621,20 @@ class _OverviewTab extends ConsumerWidget {
440
621
  final bool isAuth = user is AuthenticatedUserData;
441
622
  final bool isAdmin = user.isAdmin;
442
623
  final ov = t.admin_console.overview;
624
+ final admin = t.settings.admin;
625
+ final groups = t.admin_console.groups;
443
626
  final String account = isAuth ? user.email : ov.guest;
444
627
  final String uid = userState.user.idOrNull ?? '—';
445
628
 
446
629
  return _TabScroll(
447
630
  children: [
448
- _GroupLabel(ov.section),
449
- _ResponsiveGrid(
450
- minItemWidth: 200,
451
- children: [
452
- _StatCard(
453
- icon: Icons.cloud_done_rounded,
454
- tone: context.colors.primary,
455
- value: 'Firebase',
456
- label: ov.backend,
457
- ),
458
- // Feature-request count reads the server — admins only.
459
- if (isAdmin)
460
- _StatCard(
461
- icon: Icons.how_to_vote_rounded,
462
- tone: context.colors.primary,
463
- value: ref.watch(_adminRequestsProvider).maybeWhen(
464
- data: (l) => '${l.length}',
465
- orElse: () => '…',
466
- ),
467
- label: ov.requests_metric,
468
- ),
469
- ],
470
- ),
471
- const SizedBox(height: KasySpacing.lg),
631
+ // Admin-only data panel: live KPIs, the sign-up chart and the plan
632
+ // split. Non-admins (debug builds only) skip straight to the session
633
+ // card — the metrics need the server function, which gates by role.
634
+ if (isAdmin) ...[
635
+ const _OverviewMetricsPanel(),
636
+ const SizedBox(height: KasySpacing.lg),
637
+ ],
472
638
  _GroupLabel(ov.session_title),
473
639
  _CardShell(
474
640
  padding: const EdgeInsets.symmetric(
@@ -477,6 +643,8 @@ class _OverviewTab extends ConsumerWidget {
477
643
  ),
478
644
  child: Column(
479
645
  children: [
646
+ _InfoRow(label: ov.backend, value: 'Firebase'),
647
+ const SettingsDivider(),
480
648
  _InfoRow(
481
649
  label: ov.account,
482
650
  value: account,
@@ -489,7 +657,9 @@ class _OverviewTab extends ConsumerWidget {
489
657
  trailing: _CopyButton(
490
658
  onTap: () {
491
659
  Clipboard.setData(ClipboardData(text: uid));
492
- ref.read(toastProvider).alert(
660
+ ref
661
+ .read(toastProvider)
662
+ .alert(
493
663
  title: t.common.copied,
494
664
  text: t.settings.admin.user_id_copied,
495
665
  );
@@ -509,6 +679,33 @@ class _OverviewTab extends ConsumerWidget {
509
679
  ],
510
680
  ),
511
681
  ),
682
+ // Dev preview tools are toggled by keyboard shortcut (no buttons). Shown
683
+ // only in debug — never in production.
684
+ if (kDebugMode) ...[
685
+ const SizedBox(height: KasySpacing.lg),
686
+ _GroupLabel(groups.preview),
687
+ _CardShell(
688
+ padding: const EdgeInsets.symmetric(
689
+ horizontal: KasySpacing.md,
690
+ vertical: KasySpacing.xs,
691
+ ),
692
+ child: Column(
693
+ children: [
694
+ _ShortcutRow(
695
+ label: admin.inspector_fab_title,
696
+ keys: devInspectorShortcutLabel(),
697
+ ),
698
+ if (kIsWeb) ...[
699
+ const SettingsDivider(),
700
+ _ShortcutRow(
701
+ label: admin.device_preview_title,
702
+ keys: webDevicePreviewShortcutLabel(),
703
+ ),
704
+ ],
705
+ ],
706
+ ),
707
+ ),
708
+ ],
512
709
  const SizedBox(height: KasySpacing.md),
513
710
  Text(
514
711
  ov.users_hint,
@@ -516,18 +713,634 @@ class _OverviewTab extends ConsumerWidget {
516
713
  color: context.colors.muted,
517
714
  ),
518
715
  ),
716
+ ],
717
+ );
718
+ }
719
+ }
720
+
721
+ /// Live data panel for the Overview (admins): KPI cards, the 14-day sign-up
722
+ /// chart and the free/subscriber split. Reads the same bounded user set as the
723
+ /// Users tab; shows a skeleton while it loads and degrades to the requests KPI
724
+ /// alone if the user function fails.
725
+ class _OverviewMetricsPanel extends ConsumerWidget {
726
+ const _OverviewMetricsPanel();
727
+
728
+ @override
729
+ Widget build(BuildContext context, WidgetRef ref) {
730
+ final ov = t.admin_console.overview;
731
+ final AsyncValue<AdminUsersResult> usersAsync = ref.watch(
732
+ _adminUsersOverviewProvider,
733
+ );
734
+ final String requests = ref
735
+ .watch(_adminRequestsProvider)
736
+ .maybeWhen(data: (l) => '${l.length}', orElse: () => '…');
737
+
738
+ return usersAsync.when(
739
+ loading: () => const _OverviewSkeleton(),
740
+ error: (_, _) => Column(
741
+ crossAxisAlignment: CrossAxisAlignment.stretch,
742
+ children: [
743
+ _GroupLabel(ov.summary),
744
+ _ResponsiveGrid(
745
+ minItemWidth: 168,
746
+ children: [
747
+ _StatCard(
748
+ icon: KasyIcons.voteUp,
749
+ value: requests,
750
+ label: ov.requests_metric,
751
+ ),
752
+ ],
753
+ ),
754
+ ],
755
+ ),
756
+ data: (result) {
757
+ final m = _OverviewMetrics.from(result);
758
+ return Column(
759
+ crossAxisAlignment: CrossAxisAlignment.stretch,
760
+ children: [
761
+ _GroupLabel(ov.summary),
762
+ _ResponsiveGrid(
763
+ minItemWidth: 168,
764
+ children: [
765
+ _StatCard(
766
+ icon: KasyIcons.users,
767
+ value: _compactCount(m.total),
768
+ label: ov.total_users,
769
+ ),
770
+ _StatCard(
771
+ icon: KasyIcons.payment,
772
+ value: _compactCount(m.subscribers),
773
+ label: ov.subscribers,
774
+ ),
775
+ _StatCard(
776
+ icon: KasyIcons.northEast,
777
+ value: _compactCount(m.new7d),
778
+ label: ov.new_7d,
779
+ ),
780
+ _StatCard(
781
+ icon: KasyIcons.voteUp,
782
+ value: requests,
783
+ label: ov.requests_metric,
784
+ ),
785
+ ],
786
+ ),
787
+ const SizedBox(height: KasySpacing.lg),
788
+ _GroupLabel(ov.signups_title),
789
+ _SignupsCard(metrics: m),
790
+ const SizedBox(height: KasySpacing.lg),
791
+ _GroupLabel(ov.plan_split_title),
792
+ _PlanSplitCard(metrics: m),
793
+ if (result.truncated) ...[
794
+ const SizedBox(height: KasySpacing.sm),
795
+ Text(
796
+ ov.loaded_note(count: m.loaded),
797
+ style: context.textTheme.bodySmall?.copyWith(
798
+ color: context.colors.muted,
799
+ ),
800
+ ),
801
+ ],
802
+ ],
803
+ );
804
+ },
805
+ );
806
+ }
807
+ }
808
+
809
+ /// Abbreviates large counts (1240 → 1.2k) so KPI values never overflow.
810
+ String _compactCount(int n) {
811
+ if (n < 1000) return '$n';
812
+ if (n < 1000000) {
813
+ final double v = n / 1000;
814
+ return '${v.toStringAsFixed(v >= 100 ? 0 : 1).replaceAll('.0', '')}k';
815
+ }
816
+ final double v = n / 1000000;
817
+ return '${v.toStringAsFixed(1).replaceAll('.0', '')}M';
818
+ }
819
+
820
+ /// Derives the Overview's numbers from the loaded user set. Counts that can't be
821
+ /// known beyond the loaded window (subscribers, new sign-ups) are computed over
822
+ /// what we have; [AdminUsersResult.truncated] surfaces that to the user.
823
+ class _OverviewMetrics {
824
+ final int total; // true total (server count)
825
+ final int loaded; // size of the loaded set
826
+ final int subscribers; // within the loaded set
827
+ final int new7d; // within the loaded set
828
+ final List<int> daily; // 14 buckets, oldest → newest (today last)
829
+ final DateTime firstDay;
830
+ final DateTime lastDay;
831
+
832
+ const _OverviewMetrics({
833
+ required this.total,
834
+ required this.loaded,
835
+ required this.subscribers,
836
+ required this.new7d,
837
+ required this.daily,
838
+ required this.firstDay,
839
+ required this.lastDay,
840
+ });
841
+
842
+ int get free => (loaded - subscribers).clamp(0, loaded);
843
+ int get signups14 => daily.fold(0, (a, b) => a + b);
844
+ int get conversionPercent =>
845
+ loaded == 0 ? 0 : (subscribers / loaded * 100).round();
846
+
847
+ factory _OverviewMetrics.from(AdminUsersResult result) {
848
+ final users = result.users;
849
+ final now = DateTime.now();
850
+ final today = DateTime(now.year, now.month, now.day);
851
+ final start = today.subtract(const Duration(days: 13));
852
+ final sevenAgo = today.subtract(const Duration(days: 6));
853
+ final daily = List<int>.filled(14, 0);
854
+ int subscribers = 0;
855
+ int new7d = 0;
856
+ for (final u in users) {
857
+ if (u.subscriber) subscribers++;
858
+ final created = u.createdAt;
859
+ if (created == null) continue;
860
+ final d = DateTime(created.year, created.month, created.day);
861
+ final idx = d.difference(start).inDays;
862
+ if (idx >= 0 && idx < 14) daily[idx]++;
863
+ if (!d.isBefore(sevenAgo)) new7d++;
864
+ }
865
+ return _OverviewMetrics(
866
+ total: result.totalUsers,
867
+ loaded: users.length,
868
+ subscribers: subscribers,
869
+ new7d: new7d,
870
+ daily: daily,
871
+ firstDay: start,
872
+ lastDay: today,
873
+ );
874
+ }
875
+ }
876
+
877
+ /// Sign-up chart card: a heading with the 14-day total and the bar chart.
878
+ class _SignupsCard extends StatelessWidget {
879
+ final _OverviewMetrics metrics;
880
+ const _SignupsCard({required this.metrics});
881
+
882
+ @override
883
+ Widget build(BuildContext context) {
884
+ final ov = t.admin_console.overview;
885
+ final bool empty = metrics.signups14 == 0;
886
+ return _CardShell(
887
+ child: Column(
888
+ crossAxisAlignment: CrossAxisAlignment.start,
889
+ children: [
890
+ Row(
891
+ crossAxisAlignment: CrossAxisAlignment.start,
892
+ children: [
893
+ Expanded(
894
+ child: Column(
895
+ crossAxisAlignment: CrossAxisAlignment.start,
896
+ children: [
897
+ Text(
898
+ ov.signups_subtitle,
899
+ style: context.textTheme.bodySmall?.copyWith(
900
+ color: context.colors.muted,
901
+ ),
902
+ ),
903
+ const SizedBox(height: 2),
904
+ Text(
905
+ ov.signups_total(count: metrics.signups14),
906
+ style: context.textTheme.titleMedium?.copyWith(
907
+ color: context.colors.onSurface,
908
+ fontWeight: FontWeight.w800,
909
+ ),
910
+ ),
911
+ ],
912
+ ),
913
+ ),
914
+ ],
915
+ ),
916
+ const SizedBox(height: KasySpacing.md),
917
+ if (empty)
918
+ Padding(
919
+ padding: const EdgeInsets.symmetric(vertical: KasySpacing.lg),
920
+ child: Center(
921
+ child: Text(
922
+ ov.signups_empty,
923
+ style: context.textTheme.bodySmall?.copyWith(
924
+ color: context.colors.muted,
925
+ ),
926
+ ),
927
+ ),
928
+ )
929
+ else
930
+ _SignupsChart(metrics: metrics),
931
+ ],
932
+ ),
933
+ );
934
+ }
935
+ }
936
+
937
+ /// Interactive bar chart (no dependency): one bar per day. Hover (web) or
938
+ /// tap/scrub (touch) over a bar to highlight it and read that day's date and
939
+ /// count in a floating label — the professional dashboard behaviour.
940
+ class _SignupsChart extends StatefulWidget {
941
+ final _OverviewMetrics metrics;
942
+ const _SignupsChart({required this.metrics});
943
+
944
+ @override
945
+ State<_SignupsChart> createState() => _SignupsChartState();
946
+ }
947
+
948
+ class _SignupsChartState extends State<_SignupsChart> {
949
+ static const double _height = 124;
950
+ int? _active;
951
+
952
+ String _dm(DateTime d) =>
953
+ '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}';
954
+
955
+ @override
956
+ Widget build(BuildContext context) {
957
+ final daily = widget.metrics.daily;
958
+ final int count = daily.length;
959
+ final int maxV = daily.fold(0, (a, b) => b > a ? b : a);
960
+ final double safeMax = maxV <= 0 ? 1 : maxV.toDouble();
961
+ final Color tone = context.colors.primary;
962
+
963
+ return Column(
964
+ crossAxisAlignment: CrossAxisAlignment.stretch,
965
+ children: [
966
+ SizedBox(
967
+ height: _height,
968
+ child: LayoutBuilder(
969
+ builder: (context, c) {
970
+ final double w = c.maxWidth;
971
+ void hit(double dx) {
972
+ final int i = (dx / w * count).floor().clamp(0, count - 1);
973
+ if (i != _active) setState(() => _active = i);
974
+ }
975
+
976
+ final int? active = _active;
977
+ final double activeFactor = active == null
978
+ ? 0
979
+ : (daily[active] / safeMax).clamp(0.0, 1.0);
980
+
981
+ return TapRegion(
982
+ onTapOutside: (_) {
983
+ if (_active != null) setState(() => _active = null);
984
+ },
985
+ child: MouseRegion(
986
+ onHover: (e) => hit(e.localPosition.dx),
987
+ onExit: (_) => setState(() => _active = null),
988
+ child: GestureDetector(
989
+ behavior: HitTestBehavior.opaque,
990
+ onTapDown: (d) => hit(d.localPosition.dx),
991
+ onHorizontalDragStart: (d) => hit(d.localPosition.dx),
992
+ onHorizontalDragUpdate: (d) => hit(d.localPosition.dx),
993
+ child: Stack(
994
+ clipBehavior: Clip.none,
995
+ children: [
996
+ // Baseline behind the bars grounds the timeline.
997
+ Positioned(
998
+ left: 0,
999
+ right: 0,
1000
+ bottom: 0,
1001
+ child: Container(
1002
+ height: 1,
1003
+ color: context.colors.outline.withValues(
1004
+ alpha: 0.35,
1005
+ ),
1006
+ ),
1007
+ ),
1008
+ Row(
1009
+ crossAxisAlignment: CrossAxisAlignment.stretch,
1010
+ children: [
1011
+ for (int i = 0; i < count; i++)
1012
+ Expanded(
1013
+ child: Padding(
1014
+ padding: const EdgeInsets.symmetric(
1015
+ horizontal: 3,
1016
+ ),
1017
+ child: _Bar(
1018
+ factor: daily[i] / safeMax,
1019
+ tone: tone,
1020
+ active: active == i,
1021
+ dimmed: active != null && active != i,
1022
+ ),
1023
+ ),
1024
+ ),
1025
+ ],
1026
+ ),
1027
+ if (active != null)
1028
+ _tooltip(
1029
+ context,
1030
+ active,
1031
+ daily[active],
1032
+ activeFactor,
1033
+ w,
1034
+ count,
1035
+ ),
1036
+ ],
1037
+ ),
1038
+ ),
1039
+ ),
1040
+ );
1041
+ },
1042
+ ),
1043
+ ),
519
1044
  const SizedBox(height: KasySpacing.xs),
1045
+ Row(
1046
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
1047
+ children: [
1048
+ Text(_dm(widget.metrics.firstDay), style: _axis(context)),
1049
+ Text(_dm(widget.metrics.lastDay), style: _axis(context)),
1050
+ ],
1051
+ ),
1052
+ ],
1053
+ );
1054
+ }
1055
+
1056
+ Widget _tooltip(
1057
+ BuildContext context,
1058
+ int index,
1059
+ int value,
1060
+ double factor,
1061
+ double w,
1062
+ int count,
1063
+ ) {
1064
+ const double tipW = 78;
1065
+ final double cell = w / count;
1066
+ final double centerX = cell * (index + 0.5);
1067
+ final double left = (centerX - tipW / 2).clamp(0.0, w - tipW);
1068
+ final double bottom = (factor * _height + 10).clamp(0.0, _height - 44);
1069
+ final DateTime date = widget.metrics.firstDay.add(Duration(days: index));
1070
+ return Positioned(
1071
+ left: left,
1072
+ bottom: bottom,
1073
+ child: IgnorePointer(
1074
+ child: Container(
1075
+ width: tipW,
1076
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
1077
+ decoration: BoxDecoration(
1078
+ color: context.colors.onSurface,
1079
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
1080
+ boxShadow: [
1081
+ BoxShadow(
1082
+ color: Colors.black.withValues(alpha: 0.18),
1083
+ blurRadius: 10,
1084
+ offset: const Offset(0, 4),
1085
+ ),
1086
+ ],
1087
+ ),
1088
+ child: Column(
1089
+ mainAxisSize: MainAxisSize.min,
1090
+ children: [
1091
+ Text(
1092
+ '$value',
1093
+ style: context.textTheme.titleSmall?.copyWith(
1094
+ color: context.colors.surface,
1095
+ fontWeight: FontWeight.w800,
1096
+ height: 1,
1097
+ ),
1098
+ ),
1099
+ const SizedBox(height: 2),
1100
+ Text(
1101
+ _dm(date),
1102
+ style: context.textTheme.labelSmall?.copyWith(
1103
+ color: context.colors.surface.withValues(alpha: 0.7),
1104
+ height: 1,
1105
+ ),
1106
+ ),
1107
+ ],
1108
+ ),
1109
+ ),
1110
+ ),
1111
+ );
1112
+ }
1113
+
1114
+ TextStyle? _axis(BuildContext context) =>
1115
+ context.textTheme.labelSmall?.copyWith(color: context.colors.muted);
1116
+ }
1117
+
1118
+ class _Bar extends StatelessWidget {
1119
+ final double factor; // 0..1 of the tallest bar
1120
+ final Color tone;
1121
+ final bool active;
1122
+ final bool dimmed;
1123
+ const _Bar({
1124
+ required this.factor,
1125
+ required this.tone,
1126
+ required this.active,
1127
+ required this.dimmed,
1128
+ });
1129
+
1130
+ @override
1131
+ Widget build(BuildContext context) {
1132
+ final double f = factor.clamp(0.0, 1.0);
1133
+ final double a = active ? 1.0 : (dimmed ? 0.32 : 0.82);
1134
+ return Align(
1135
+ alignment: Alignment.bottomCenter,
1136
+ child: FractionallySizedBox(
1137
+ heightFactor: f <= 0 ? null : f,
1138
+ widthFactor: 1,
1139
+ child: AnimatedContainer(
1140
+ duration: const Duration(milliseconds: 120),
1141
+ curve: Curves.easeOut,
1142
+ // Zero days keep a faint nub so the axis still reads as a timeline.
1143
+ height: f <= 0 ? 3 : null,
1144
+ decoration: BoxDecoration(
1145
+ gradient: f <= 0
1146
+ ? null
1147
+ : LinearGradient(
1148
+ begin: Alignment.topCenter,
1149
+ end: Alignment.bottomCenter,
1150
+ colors: [
1151
+ tone.withValues(alpha: a),
1152
+ tone.withValues(alpha: a * 0.6),
1153
+ ],
1154
+ ),
1155
+ color: f <= 0
1156
+ ? context.colors.outline.withValues(alpha: 0.5)
1157
+ : null,
1158
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(5)),
1159
+ ),
1160
+ ),
1161
+ ),
1162
+ );
1163
+ }
1164
+ }
1165
+
1166
+ /// Free vs subscriber split: a conversion headline, a proportion bar and a
1167
+ /// legend with the exact counts.
1168
+ class _PlanSplitCard extends StatelessWidget {
1169
+ final _OverviewMetrics metrics;
1170
+ const _PlanSplitCard({required this.metrics});
1171
+
1172
+ @override
1173
+ Widget build(BuildContext context) {
1174
+ final ov = t.admin_console.overview;
1175
+ final int subs = metrics.subscribers;
1176
+ final int free = metrics.free;
1177
+ final Color subColor = context.colors.success;
1178
+ final Color track = context.colors.surfaceNeutralSoft;
1179
+
1180
+ return _CardShell(
1181
+ child: Column(
1182
+ crossAxisAlignment: CrossAxisAlignment.start,
1183
+ children: [
1184
+ Text(
1185
+ ov.conversion(percent: '${metrics.conversionPercent}%'),
1186
+ style: context.textTheme.titleMedium?.copyWith(
1187
+ color: context.colors.onSurface,
1188
+ fontWeight: FontWeight.w800,
1189
+ ),
1190
+ ),
1191
+ const SizedBox(height: KasySpacing.smd),
1192
+ ClipRRect(
1193
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
1194
+ child: SizedBox(
1195
+ height: 12,
1196
+ child: Row(
1197
+ children: [
1198
+ if (subs > 0)
1199
+ Expanded(
1200
+ flex: subs,
1201
+ child: ColoredBox(color: subColor),
1202
+ ),
1203
+ if (free > 0)
1204
+ Expanded(
1205
+ flex: free,
1206
+ child: ColoredBox(color: track),
1207
+ ),
1208
+ if (subs == 0 && free == 0)
1209
+ Expanded(child: ColoredBox(color: track)),
1210
+ ],
1211
+ ),
1212
+ ),
1213
+ ),
1214
+ const SizedBox(height: KasySpacing.smd),
1215
+ Row(
1216
+ children: [
1217
+ _LegendDot(color: subColor, label: ov.subscriber, value: subs),
1218
+ const SizedBox(width: KasySpacing.lg),
1219
+ _LegendDot(
1220
+ color: track,
1221
+ borderColor: context.colors.outline,
1222
+ label: ov.free,
1223
+ value: free,
1224
+ ),
1225
+ ],
1226
+ ),
1227
+ ],
1228
+ ),
1229
+ );
1230
+ }
1231
+ }
1232
+
1233
+ class _LegendDot extends StatelessWidget {
1234
+ final Color color;
1235
+ final Color? borderColor;
1236
+ final String label;
1237
+ final int value;
1238
+ const _LegendDot({
1239
+ required this.color,
1240
+ required this.label,
1241
+ required this.value,
1242
+ this.borderColor,
1243
+ });
1244
+
1245
+ @override
1246
+ Widget build(BuildContext context) {
1247
+ return Row(
1248
+ mainAxisSize: MainAxisSize.min,
1249
+ children: [
1250
+ Container(
1251
+ width: 10,
1252
+ height: 10,
1253
+ decoration: BoxDecoration(
1254
+ color: color,
1255
+ shape: BoxShape.circle,
1256
+ border: borderColor != null
1257
+ ? Border.all(color: borderColor!)
1258
+ : null,
1259
+ ),
1260
+ ),
1261
+ const SizedBox(width: 6),
520
1262
  Text(
521
- ov.debug_note,
1263
+ label,
522
1264
  style: context.textTheme.bodySmall?.copyWith(
523
1265
  color: context.colors.muted,
524
1266
  ),
525
1267
  ),
1268
+ const SizedBox(width: 4),
1269
+ Text(
1270
+ '$value',
1271
+ style: context.textTheme.bodySmall?.copyWith(
1272
+ color: context.colors.onSurface,
1273
+ fontWeight: FontWeight.w700,
1274
+ ),
1275
+ ),
526
1276
  ],
527
1277
  );
528
1278
  }
529
1279
  }
530
1280
 
1281
+ /// Loading placeholder for the metrics panel — skeleton KPI cards plus a chart
1282
+ /// block, matching the real layout so nothing jumps when data arrives.
1283
+ class _OverviewSkeleton extends StatelessWidget {
1284
+ const _OverviewSkeleton();
1285
+
1286
+ @override
1287
+ Widget build(BuildContext context) {
1288
+ final ov = t.admin_console.overview;
1289
+ return KasySkeletonGroup(
1290
+ child: Column(
1291
+ crossAxisAlignment: CrossAxisAlignment.stretch,
1292
+ children: [
1293
+ _GroupLabel(ov.summary),
1294
+ const _ResponsiveGrid(
1295
+ minItemWidth: 168,
1296
+ children: [
1297
+ _StatCardSkeleton(),
1298
+ _StatCardSkeleton(),
1299
+ _StatCardSkeleton(),
1300
+ _StatCardSkeleton(),
1301
+ ],
1302
+ ),
1303
+ const SizedBox(height: KasySpacing.lg),
1304
+ _GroupLabel(ov.signups_title),
1305
+ const _CardShell(
1306
+ child: Column(
1307
+ crossAxisAlignment: CrossAxisAlignment.stretch,
1308
+ children: [
1309
+ KasySkeleton(width: 120, height: 12),
1310
+ SizedBox(height: 8),
1311
+ KasySkeleton(width: 70, height: 18),
1312
+ SizedBox(height: KasySpacing.md),
1313
+ KasySkeleton(width: double.infinity, height: 116),
1314
+ ],
1315
+ ),
1316
+ ),
1317
+ ],
1318
+ ),
1319
+ );
1320
+ }
1321
+ }
1322
+
1323
+ class _StatCardSkeleton extends StatelessWidget {
1324
+ const _StatCardSkeleton();
1325
+
1326
+ @override
1327
+ Widget build(BuildContext context) {
1328
+ return const _CardShell(
1329
+ child: Column(
1330
+ crossAxisAlignment: CrossAxisAlignment.start,
1331
+ mainAxisSize: MainAxisSize.min,
1332
+ children: [
1333
+ KasySkeleton.circle(size: 40),
1334
+ SizedBox(height: KasySpacing.smd),
1335
+ KasySkeleton(width: 56, height: 18),
1336
+ SizedBox(height: 6),
1337
+ KasySkeleton(width: 84, height: 11),
1338
+ ],
1339
+ ),
1340
+ );
1341
+ }
1342
+ }
1343
+
531
1344
  class _InfoRow extends StatelessWidget {
532
1345
  final String label;
533
1346
  final String value;
@@ -571,22 +1384,52 @@ class _InfoRow extends StatelessWidget {
571
1384
  }
572
1385
  }
573
1386
 
1387
+ /// A dev-tool name with its global keyboard shortcut on the right (read-only).
1388
+ class _ShortcutRow extends StatelessWidget {
1389
+ final String label;
1390
+ final String keys;
1391
+ const _ShortcutRow({required this.label, required this.keys});
1392
+
1393
+ @override
1394
+ Widget build(BuildContext context) {
1395
+ return Padding(
1396
+ padding: const EdgeInsets.symmetric(vertical: KasySpacing.smd),
1397
+ child: Row(
1398
+ children: [
1399
+ Expanded(
1400
+ child: Text(
1401
+ label,
1402
+ style: context.textTheme.bodyMedium?.copyWith(
1403
+ color: context.colors.onSurface,
1404
+ ),
1405
+ ),
1406
+ ),
1407
+ const SizedBox(width: KasySpacing.sm),
1408
+ Text(
1409
+ keys,
1410
+ style: context.kasyTextTheme.rowValue.copyWith(
1411
+ color: context.colors.muted,
1412
+ ),
1413
+ ),
1414
+ ],
1415
+ ),
1416
+ );
1417
+ }
1418
+ }
1419
+
574
1420
  class _CopyButton extends StatelessWidget {
575
1421
  final VoidCallback onTap;
576
1422
  const _CopyButton({required this.onTap});
577
1423
 
578
1424
  @override
579
1425
  Widget build(BuildContext context) {
580
- return InkWell(
1426
+ return KasyHover(
581
1427
  onTap: onTap,
1428
+ focusable: true,
582
1429
  borderRadius: BorderRadius.circular(KasyRadius.sm),
583
1430
  child: Padding(
584
1431
  padding: const EdgeInsets.all(6),
585
- child: Icon(
586
- Icons.content_copy_rounded,
587
- size: 16,
588
- color: context.colors.primary,
589
- ),
1432
+ child: Icon(KasyIcons.copy, size: 16, color: context.colors.primary),
590
1433
  ),
591
1434
  );
592
1435
  }
@@ -655,7 +1498,7 @@ class _UsersTab extends ConsumerWidget {
655
1498
  final u = t.admin_console.users;
656
1499
  if (!isAdmin) {
657
1500
  return _EmptyState(
658
- icon: Icons.lock_outline_rounded,
1501
+ icon: KasyIcons.security,
659
1502
  title: u.title,
660
1503
  message: t.admin_console.requires_admin,
661
1504
  );
@@ -673,13 +1516,14 @@ class _RequestsTab extends ConsumerWidget {
673
1516
  final r = t.admin_console.requests;
674
1517
  if (!isAdmin) {
675
1518
  return _EmptyState(
676
- icon: Icons.lock_outline_rounded,
1519
+ icon: KasyIcons.security,
677
1520
  title: r.title,
678
1521
  message: t.admin_console.requires_admin,
679
1522
  );
680
1523
  }
681
- final AsyncValue<List<FeatureRequestEntity>> async =
682
- ref.watch(_adminRequestsProvider);
1524
+ final AsyncValue<List<FeatureRequestEntity>> async = ref.watch(
1525
+ _adminRequestsProvider,
1526
+ );
683
1527
  return async.when(
684
1528
  loading: () => const Center(child: CircularProgressIndicator.adaptive()),
685
1529
  error: (_, _) => _EmptyState(
@@ -695,9 +1539,12 @@ class _RequestsTab extends ConsumerWidget {
695
1539
  message: r.empty,
696
1540
  );
697
1541
  }
1542
+ // Newest first — the most recently submitted request leads the list.
1543
+ final sorted = [...list]
1544
+ ..sort((a, b) => b.creationDate.compareTo(a.creationDate));
698
1545
  return _TabScroll(
699
1546
  children: [
700
- for (final req in list) ...[
1547
+ for (final req in sorted) ...[
701
1548
  _RequestCard(req),
702
1549
  const SizedBox(height: KasySpacing.md),
703
1550
  ],
@@ -787,7 +1634,9 @@ class _RequestCard extends ConsumerWidget {
787
1634
  .setActive(req.id!, v);
788
1635
  ref.invalidate(_adminRequestsProvider);
789
1636
  if (context.mounted) {
790
- ref.read(toastProvider).alert(title: t.common.saved, text: r.saved);
1637
+ ref
1638
+ .read(toastProvider)
1639
+ .alert(title: t.common.saved, text: r.saved);
791
1640
  }
792
1641
  },
793
1642
  ),
@@ -795,7 +1644,9 @@ class _RequestCard extends ConsumerWidget {
795
1644
  const Spacer(),
796
1645
  KasyButton(
797
1646
  label: r.edit,
798
- variant: KasyButtonVariant.soft,
1647
+ variant: KasyButtonVariant.outline,
1648
+ size: KasyButtonSize.small,
1649
+ icon: KasyIcons.language,
799
1650
  onPressed: () => _openRequestEditor(context, req),
800
1651
  ),
801
1652
  ],
@@ -843,7 +1694,10 @@ class _VotesChip extends StatelessWidget {
843
1694
  }
844
1695
  }
845
1696
 
846
- Future<void> _openRequestEditor(BuildContext context, FeatureRequestEntity req) {
1697
+ Future<void> _openRequestEditor(
1698
+ BuildContext context,
1699
+ FeatureRequestEntity req,
1700
+ ) {
847
1701
  return showKasyBlurBottomSheet<void>(
848
1702
  context: context,
849
1703
  builder: (_) => _RequestEditorSheet(req: req),
@@ -902,7 +1756,9 @@ class _RequestEditorSheetState extends ConsumerState<_RequestEditorSheet> {
902
1756
  final r = t.admin_console.requests;
903
1757
  setState(() => _saving = true);
904
1758
  try {
905
- await ref.read(featureRequestApiProvider).updateTexts(
1759
+ await ref
1760
+ .read(featureRequestApiProvider)
1761
+ .updateTexts(
906
1762
  id: widget.req.id!,
907
1763
  title: {for (final l in _langs) l: _title[l]!.text.trim()},
908
1764
  description: {for (final l in _langs) l: _desc[l]!.text.trim()},
@@ -968,262 +1824,363 @@ class _RequestEditorSheetState extends ConsumerState<_RequestEditorSheet> {
968
1824
  }
969
1825
 
970
1826
  // ─────────────────────────────────────────────────────────────────────────────
971
- // Kitfeature demos + the component gallery
1827
+ // Tools sub-screens each is a real console section (body only; the admin
1828
+ // shell supplies the chrome), reached from the sidebar's "Ferramentas" submenu.
972
1829
  // ─────────────────────────────────────────────────────────────────────────────
973
1830
 
974
- class _KitTab extends ConsumerWidget {
975
- const _KitTab();
1831
+ /// Settings-style card: rows separated by hairline dividers — the exact clean
1832
+ /// list pattern of the Settings screen (no busy card grid). Shared by the Tools
1833
+ /// sections.
1834
+ Widget _groupCard(List<Widget> rows) => _CardShell(
1835
+ // Tiles carry their own vertical padding, so the card frame stays tight
1836
+ // (vertical xs, not md) — a single-row card then reads as one clean line
1837
+ // instead of an oversized box. Matches the Overview's session card.
1838
+ padding: const EdgeInsets.symmetric(
1839
+ horizontal: KasySpacing.md,
1840
+ vertical: KasySpacing.xs,
1841
+ ),
1842
+ child: Column(
1843
+ mainAxisSize: MainAxisSize.min,
1844
+ children: [
1845
+ for (int i = 0; i < rows.length; i++) ...[
1846
+ if (i > 0) const SettingsDivider(),
1847
+ rows[i],
1848
+ ],
1849
+ ],
1850
+ ),
1851
+ );
1852
+
1853
+ /// Paywalls panel — every variant as a rich card: a friendly name, a short
1854
+ /// description and its code (the id you hand the assistant to pick one), with a
1855
+ /// copy button. Tapping a card opens the live preview. Production section; the
1856
+ /// preview route is debug-only, so it only opens a screen in debug builds.
1857
+ class _PaywallsTab extends StatelessWidget {
1858
+ const _PaywallsTab();
1859
+
1860
+ // Ordered simplest → richest so the gallery reads as a deliberate sequence.
1861
+ static const List<String> _order = [
1862
+ 'minimal',
1863
+ 'basic',
1864
+ 'basicRow',
1865
+ 'withSwitch',
1866
+ ];
976
1867
 
977
1868
  @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;
982
-
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
- ];
1022
-
1869
+ Widget build(BuildContext context) {
1870
+ final admin = t.settings.admin;
1871
+ final pw = t.admin_console.paywalls;
1023
1872
  return _TabScroll(
1024
1873
  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'),
1874
+ _GroupLabel(admin.paywalls),
1875
+ Padding(
1876
+ padding: const EdgeInsets.only(
1877
+ left: KasySpacing.xs,
1878
+ bottom: KasySpacing.md,
1879
+ ),
1880
+ child: Text(
1881
+ pw.subtitle,
1882
+ style: context.textTheme.bodySmall?.copyWith(
1883
+ color: context.colors.muted,
1884
+ height: 1.35,
1039
1885
  ),
1040
- ],
1886
+ ),
1041
1887
  ),
1888
+ for (final id in _order) ...[
1889
+ _PaywallCard(
1890
+ paywall: PaywallFactory.values.firstWhere((p) => p.name == id),
1891
+ ),
1892
+ const SizedBox(height: KasySpacing.md),
1893
+ ],
1042
1894
  ],
1043
1895
  );
1044
1896
  }
1045
1897
  }
1046
1898
 
1047
- // ─────────────────────────────────────────────────────────────────────────────
1048
- // Tools every developer toggle/action migrated from the old admin sheet
1049
- // ─────────────────────────────────────────────────────────────────────────────
1899
+ /// Localized friendly title + description for a paywall id.
1900
+ ({String title, String desc}) _paywallMeta(String id) {
1901
+ final pw = t.admin_console.paywalls;
1902
+ return switch (id) {
1903
+ 'withSwitch' => (title: pw.with_switch_title, desc: pw.with_switch_desc),
1904
+ 'basic' => (title: pw.basic_title, desc: pw.basic_desc),
1905
+ 'basicRow' => (title: pw.basic_row_title, desc: pw.basic_row_desc),
1906
+ _ => (title: pw.minimal_title, desc: pw.minimal_desc),
1907
+ };
1908
+ }
1050
1909
 
1051
- class _ToolsTab extends ConsumerWidget {
1052
- const _ToolsTab();
1910
+ class _PaywallCard extends ConsumerWidget {
1911
+ final PaywallFactory paywall;
1912
+ const _PaywallCard({required this.paywall});
1053
1913
 
1054
1914
  @override
1055
1915
  Widget build(BuildContext context, WidgetRef ref) {
1056
- final userState = ref.watch(userStateNotifierProvider);
1057
- final admin = t.settings.admin;
1058
- 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);
1916
+ final pw = t.admin_console.paywalls;
1917
+ final meta = _paywallMeta(paywall.name);
1918
+ return KasyHover(
1919
+ onTap: () => context.push(adminRoutePremiumPreview(paywall.name)),
1920
+ borderRadius: BorderRadius.circular(_cardRadius),
1921
+ semanticLabel: meta.title,
1922
+ child: _CardShell(
1923
+ child: Column(
1924
+ crossAxisAlignment: CrossAxisAlignment.start,
1925
+ children: [
1926
+ Row(
1927
+ children: [
1928
+ Expanded(
1929
+ child: Text(
1930
+ meta.title,
1931
+ style: context.textTheme.titleSmall?.copyWith(
1932
+ color: context.colors.onSurface,
1933
+ fontWeight: FontWeight.w700,
1934
+ ),
1935
+ ),
1936
+ ),
1937
+ const SizedBox(width: KasySpacing.sm),
1938
+ Icon(
1939
+ KasyIcons.chevronRight,
1940
+ size: 18,
1941
+ color: context.colors.muted,
1942
+ ),
1943
+ ],
1944
+ ),
1945
+ const SizedBox(height: 3),
1946
+ Text(
1947
+ meta.desc,
1948
+ style: context.textTheme.bodySmall?.copyWith(
1949
+ color: context.colors.muted,
1950
+ height: 1.35,
1951
+ ),
1952
+ ),
1953
+ const SizedBox(height: KasySpacing.smd),
1954
+ _CodeChip(
1955
+ code: paywall.name,
1956
+ onCopy: () {
1957
+ Clipboard.setData(ClipboardData(text: paywall.name));
1958
+ ref
1959
+ .read(toastProvider)
1960
+ .alert(title: t.common.copied, text: pw.code_copied);
1094
1961
  },
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',
1962
+ ),
1963
+ ],
1109
1964
  ),
1110
1965
  ),
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,
1966
+ );
1967
+ }
1968
+ }
1969
+
1970
+ /// Monospace code pill (the paywall id) with a copy icon — tap to copy and hand
1971
+ /// it to the assistant. Its own tap target, so it never triggers the card.
1972
+ class _CodeChip extends StatelessWidget {
1973
+ final String code;
1974
+ final VoidCallback onCopy;
1975
+ const _CodeChip({required this.code, required this.onCopy});
1976
+
1977
+ @override
1978
+ Widget build(BuildContext context) {
1979
+ return KasyHover(
1980
+ onTap: onCopy,
1981
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
1982
+ semanticLabel: t.admin_console.paywalls.copy_code,
1983
+ child: Container(
1984
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
1985
+ decoration: BoxDecoration(
1986
+ color: context.colors.surfaceNeutralSoft,
1987
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
1988
+ ),
1989
+ child: Row(
1990
+ mainAxisSize: MainAxisSize.min,
1991
+ children: [
1992
+ Text(
1993
+ code,
1994
+ style: context.textTheme.labelSmall?.copyWith(
1995
+ color: context.colors.muted,
1996
+ fontFamily: 'monospace',
1997
+ fontWeight: FontWeight.w500,
1998
+ ),
1999
+ ),
2000
+ const SizedBox(width: 6),
2001
+ Icon(KasyIcons.copy, size: 13, color: context.colors.primary),
2002
+ ],
1120
2003
  ),
1121
2004
  ),
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
- ];
2005
+ );
2006
+ }
2007
+ }
2008
+
2009
+ /// The UI-kit catalog as a console section (debug only). Width-capped + centered
2010
+ /// to the same [_contentMaxWidth] as every other section (Paywalls, Overview…),
2011
+ /// so the console reads as one consistent column instead of the catalog
2012
+ /// stretching edge-to-edge on desktop.
2013
+ class _ComponentsTab extends StatelessWidget {
2014
+ const _ComponentsTab();
2015
+
2016
+ @override
2017
+ Widget build(BuildContext context) =>
2018
+ const _MaxWidth(child: HomeComponentsCatalog());
2019
+ }
2020
+
2021
+ /// Developer tooling (debug only): identity helpers, debug actions and a
2022
+ /// local-notification test — the leftovers that aren't worth a screen of their
2023
+ /// own, grouped as clean Settings-style lists.
2024
+ class _DebugTab extends ConsumerWidget {
2025
+ const _DebugTab();
2026
+
2027
+ @override
2028
+ Widget build(BuildContext context, WidgetRef ref) {
2029
+ final page = t.home.features_page;
2030
+ final admin = t.settings.admin;
2031
+ final groups = t.admin_console.groups;
2032
+ final userState = ref.watch(userStateNotifierProvider);
1206
2033
 
1207
2034
  return _TabScroll(
1208
2035
  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
- ],
2036
+ // ── Identity ────────────────────────────────────────────────────────
2037
+ _GroupLabel(groups.identity),
2038
+ _groupCard([
2039
+ SettingsTile(
2040
+ icon: KasyIcons.person,
2041
+ title: admin.copy_user_id,
2042
+ onTap: () {
2043
+ Clipboard.setData(
2044
+ ClipboardData(text: userState.user.idOrNull ?? 'no-id (guest)'),
2045
+ );
2046
+ ref
2047
+ .read(toastProvider)
2048
+ .alert(title: t.common.copied, text: admin.user_id_copied);
2049
+ },
1219
2050
  ),
1220
- ),
2051
+ SettingsTile(
2052
+ icon: KasyIcons.notification,
2053
+ title: admin.copy_fcm_token,
2054
+ onTap: () async {
2055
+ if (kIsWeb) {
2056
+ ref
2057
+ .read(toastProvider)
2058
+ .alert(
2059
+ title: t.common.native_only_title,
2060
+ text: admin.native_only,
2061
+ );
2062
+ return;
2063
+ }
2064
+ final token = await FirebaseMessaging.instance.getToken();
2065
+ if (token == null) {
2066
+ ref
2067
+ .read(toastProvider)
2068
+ .alert(
2069
+ title: t.common.unavailable,
2070
+ text: admin.fcm_token_unavailable,
2071
+ );
2072
+ return;
2073
+ }
2074
+ await Clipboard.setData(ClipboardData(text: token));
2075
+ ref
2076
+ .read(toastProvider)
2077
+ .alert(title: t.common.copied, text: admin.fcm_token_copied);
2078
+ },
2079
+ ),
2080
+ SettingsTile(
2081
+ icon: KasyIcons.notification,
2082
+ title: admin.ask_notification,
2083
+ onTap: () {
2084
+ if (kIsWeb) {
2085
+ ref
2086
+ .read(toastProvider)
2087
+ .alert(
2088
+ title: t.common.native_only_title,
2089
+ text: admin.native_only,
2090
+ );
2091
+ return;
2092
+ }
2093
+ ref.read(notificationsSettingsProvider).askPermission();
2094
+ },
2095
+ ),
2096
+ ]),
2097
+
1221
2098
  const SizedBox(height: KasySpacing.lg),
2099
+
2100
+ // ── Debug actions ───────────────────────────────────────────────────
1222
2101
  _GroupLabel(groups.debug_actions),
1223
- _ResponsiveGrid(minItemWidth: 280, maxCols: 2, children: tools),
2102
+ _groupCard([
2103
+ SettingsTile(
2104
+ icon: KasyIcons.note,
2105
+ title: admin.update_bottom_sheet,
2106
+ // Preview the sheet over the current screen; dismissing just closes
2107
+ // it and returns here, without navigating away.
2108
+ onTap: () => showUpdateBottomSheet(
2109
+ context: navigatorKey.currentContext!,
2110
+ version: '0.0.0',
2111
+ ),
2112
+ ),
2113
+ SettingsTile(
2114
+ icon: KasyIcons.download,
2115
+ title: admin.preview_update_available,
2116
+ // Previews the optional (dismissible) sheet. The forced variant is
2117
+ // the same layout, blocking — test it via app_min_version.
2118
+ onTap: () => showUpdateAvailableSheet(
2119
+ navigatorKey.currentContext!,
2120
+ forced: false,
2121
+ ),
2122
+ ),
2123
+ SettingsTile(
2124
+ icon: KasyIcons.check,
2125
+ title: admin.test_onboarding,
2126
+ // Preview mode: walks the onboarding screens with every real side
2127
+ // effect suppressed (no guest account, no profile writes, no
2128
+ // permission prompts) and returns here when done.
2129
+ onTap: () =>
2130
+ ref.read(goRouterProvider).go('/onboarding?preview=true'),
2131
+ ),
2132
+ SettingsTile(
2133
+ icon: KasyIcons.star,
2134
+ title: admin.ask_review,
2135
+ // Has a design (the review dialog), previewable on web too — only
2136
+ // the store action no-ops there.
2137
+ onTap: () => showReviewDialog(context, ref, force: true),
2138
+ ),
2139
+ // Developer-only: its drill-down route (adminRouteHomeWidgets) is
2140
+ // registered only in kDebugMode, so hide the tile in release rather
2141
+ // than push a route that doesn't exist there.
2142
+ if (kDebugMode)
2143
+ SettingsTile(
2144
+ icon: KasyIcons.message,
2145
+ title: admin.home_widgets_panel,
2146
+ // Pushed full-screen (its own back button), a drill-down from here.
2147
+ onTap: () => context.push(adminRouteHomeWidgets),
2148
+ ),
2149
+ ]),
2150
+
1224
2151
  const SizedBox(height: KasySpacing.lg),
1225
- _GroupLabel(groups.identity),
1226
- _ResponsiveGrid(minItemWidth: 280, maxCols: 2, children: identity),
2152
+
2153
+ // ── Notification test ───────────────────────────────────────────────
2154
+ _GroupLabel(groups.notification_test),
2155
+ _groupCard([
2156
+ SettingsTile(
2157
+ icon: KasyIcons.notification,
2158
+ title: page.notification_title,
2159
+ onTap: () {
2160
+ // Local notifications don't fire on web — tell the user instead
2161
+ // of doing nothing when tapped.
2162
+ if (kIsWeb) {
2163
+ ref
2164
+ .read(toastProvider)
2165
+ .alert(
2166
+ title: t.common.native_only_title,
2167
+ text: admin.native_only,
2168
+ );
2169
+ return;
2170
+ }
2171
+ final settings = ref.read(notificationsSettingsProvider);
2172
+ final localNotifier = ref.read(localNotifierProvider);
2173
+ kasy_kit.Notification.withData(
2174
+ id: 'fake-id',
2175
+ title: page.notification_demo_title,
2176
+ body: page.notification_demo_body,
2177
+ createdAt: DateTime.now(),
2178
+ notifier: localNotifier,
2179
+ notifierSettings: settings,
2180
+ ).show();
2181
+ },
2182
+ ),
2183
+ ]),
1227
2184
  ],
1228
2185
  );
1229
2186
  }