kasy-cli 1.38.0 → 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 (102) hide show
  1. package/lib/scaffold/CHANGELOG.json +14 -0
  2. package/lib/scaffold/backends/api/patch/README.md +15 -0
  3. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
  4. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  5. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  6. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  7. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  8. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  9. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  10. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  11. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  12. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  13. package/lib/scaffold/shared/generator-utils.js +12 -6
  14. package/package.json +1 -1
  15. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  16. package/templates/firebase/DESIGN_SYSTEM.md +22 -8
  17. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  18. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  19. package/templates/firebase/assets/icons/facebook.svg +49 -0
  20. package/templates/firebase/assets/icons/google.svg +1 -0
  21. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  22. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  23. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  24. package/templates/firebase/lib/components/components.dart +1 -1
  25. package/templates/firebase/lib/components/kasy_app_bar.dart +314 -14
  26. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  27. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  28. package/templates/firebase/lib/components/kasy_sidebar.dart +18 -6
  29. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  30. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  31. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +27 -18
  32. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +12 -10
  33. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  34. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  35. package/templates/firebase/lib/core/data/api/user_api.dart +11 -0
  36. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  37. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
  38. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  39. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  40. package/templates/firebase/lib/core/states/user_state_notifier.dart +28 -1
  41. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  42. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  43. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +51 -19
  44. package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
  45. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  46. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  47. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  48. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  49. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  50. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  51. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  52. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  53. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  54. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  55. package/templates/firebase/lib/features/home/home_components_page.dart +253 -125
  56. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
  57. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  58. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  59. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  60. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +111 -57
  61. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  62. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -4
  63. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  64. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  65. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  66. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +2 -2
  67. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  68. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  69. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  70. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  71. package/templates/firebase/lib/features/settings/settings_page.dart +53 -32
  72. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +895 -111
  73. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  74. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  75. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +171 -41
  76. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +1 -1
  77. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  78. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +48 -47
  79. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  80. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  81. package/templates/firebase/lib/i18n/en.i18n.json +749 -712
  82. package/templates/firebase/lib/i18n/es.i18n.json +749 -712
  83. package/templates/firebase/lib/i18n/pt.i18n.json +749 -712
  84. package/templates/firebase/lib/main.dart +20 -7
  85. package/templates/firebase/lib/router.dart +32 -26
  86. package/templates/firebase/pubspec.yaml +2 -1
  87. package/templates/firebase/test/admin_shell_chrome_test.dart +11 -5
  88. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  89. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  90. package/templates/firebase/tool/design_check.dart +9 -0
  91. package/templates/firebase/assets/icons/apple.png +0 -0
  92. package/templates/firebase/assets/icons/facebook.png +0 -0
  93. package/templates/firebase/assets/icons/google.png +0 -0
  94. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  95. package/templates/firebase/lib/components/kasy_web_header.dart +0 -218
  96. package/templates/firebase/lib/core/chrome/web_header_scope.dart +0 -20
  97. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  98. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  99. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  100. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  101. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -179
  102. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
@@ -8,6 +8,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
10
  import 'package:kasy_kit/components/kasy_sidebar.dart';
11
+ import 'package:kasy_kit/components/kasy_skeleton.dart';
11
12
  import 'package:kasy_kit/components/kasy_status_tag.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';
@@ -20,6 +21,7 @@ import 'package:kasy_kit/core/states/user_state_notifier.dart';
20
21
  import 'package:kasy_kit/core/theme/theme.dart';
21
22
  import 'package:kasy_kit/core/toast/toast_service.dart';
22
23
  import 'package:kasy_kit/core/web_device_preview/web_device_preview.dart';
24
+ import 'package:kasy_kit/core/widgets/kasy_hover.dart';
23
25
  import 'package:kasy_kit/core/widgets/responsive_layout.dart';
24
26
  import 'package:kasy_kit/core/widgets/update_bottom_sheet.dart';
25
27
  import 'package:kasy_kit/features/feedbacks/api/entities/feature_request_entity.dart';
@@ -29,6 +31,7 @@ import 'package:kasy_kit/features/notifications/api/local_notifier.dart';
29
31
  import 'package:kasy_kit/features/notifications/providers/models/notification.dart'
30
32
  as kasy_kit;
31
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';
32
35
  import 'package:kasy_kit/features/settings/ui/components/admin/admin_users_tab.dart';
33
36
  import 'package:kasy_kit/features/settings/ui/components/admin/send_push_notification_page.dart';
34
37
  import 'package:kasy_kit/features/settings/ui/widgets/kasy_user_avatar.dart';
@@ -47,8 +50,9 @@ import 'package:package_info_plus/package_info_plus.dart';
47
50
  ///
48
51
  /// The first three are top-level rows under the "ADMIN" label; the rest live
49
52
  /// inside the sidebar's expandable "Ferramentas" submenu
50
- /// ([AdminSectionDef.inToolsGroup]). [components] and [debug] are developer-only
51
- /// (their branches register only in [kDebugMode]).
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].
52
56
  enum AdminSection {
53
57
  overview,
54
58
  users,
@@ -82,10 +86,10 @@ class AdminSectionDef {
82
86
  const String adminBasePath = '/admin';
83
87
 
84
88
  /// 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
+ /// 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.
89
93
  List<AdminSectionDef> adminSections() => [
90
94
  AdminSectionDef(
91
95
  id: AdminSection.overview,
@@ -120,22 +124,23 @@ List<AdminSectionDef> adminSections() => [
120
124
  build: () => const _PaywallsTab(),
121
125
  inToolsGroup: true,
122
126
  ),
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
- ],
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
+ ),
139
144
  ];
140
145
 
141
146
  /// Localized sidebar / app-bar label for a section.
@@ -337,10 +342,7 @@ class _AdminShellState extends ConsumerState<AdminShell> {
337
342
  backgroundColor: context.colors.background,
338
343
  // Square edge / flat / surface fill all come from the global DrawerThemeData
339
344
  // (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
- ),
345
+ drawer: Drawer(width: kasySidebarWidth, child: buildRail(isDrawer: true)),
344
346
  body: Column(
345
347
  crossAxisAlignment: CrossAxisAlignment.stretch,
346
348
  children: [
@@ -367,8 +369,18 @@ class _AdminShellState extends ConsumerState<AdminShell> {
367
369
  /// the Requests tab and the Overview count. Invalidate to refresh after a change.
368
370
  final _adminRequestsProvider =
369
371
  FutureProvider.autoDispose<List<FeatureRequestEntity>>((ref) {
370
- return ref.read(featureRequestApiProvider).getAll();
371
- });
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
+ });
372
384
 
373
385
  // ─────────────────────────────────────────────────────────────────────────────
374
386
  // Layout primitives
@@ -472,7 +484,8 @@ class _CardShell extends StatelessWidget {
472
484
  }
473
485
  }
474
486
 
475
- /// 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.
476
489
  class _IconBubble extends StatelessWidget {
477
490
  final IconData icon;
478
491
  final Color tone;
@@ -481,27 +494,36 @@ class _IconBubble extends StatelessWidget {
481
494
 
482
495
  @override
483
496
  Widget build(BuildContext context) {
497
+ final double a = context.isDark ? 0.26 : 0.15;
484
498
  return Container(
485
499
  width: size,
486
500
  height: size,
487
501
  decoration: BoxDecoration(
488
- 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
+ ),
489
510
  borderRadius: BorderRadius.circular(size * 0.3),
511
+ border: Border.all(color: tone.withValues(alpha: 0.18)),
490
512
  ),
491
513
  child: Icon(icon, size: size * 0.5, color: tone),
492
514
  );
493
515
  }
494
516
  }
495
517
 
496
- /// 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.
497
521
  class _StatCard extends StatelessWidget {
498
522
  final IconData icon;
499
- final Color tone;
500
523
  final String value;
501
524
  final String label;
502
525
  const _StatCard({
503
526
  required this.icon,
504
- required this.tone,
505
527
  required this.value,
506
528
  required this.label,
507
529
  });
@@ -513,7 +535,7 @@ class _StatCard extends StatelessWidget {
513
535
  crossAxisAlignment: CrossAxisAlignment.start,
514
536
  mainAxisSize: MainAxisSize.min,
515
537
  children: [
516
- _IconBubble(icon: icon, tone: tone),
538
+ Icon(icon, size: 22, color: context.colors.muted),
517
539
  const SizedBox(height: KasySpacing.smd),
518
540
  Text(
519
541
  value,
@@ -543,10 +565,7 @@ class _StatCard extends StatelessWidget {
543
565
  class _ResponsiveGrid extends StatelessWidget {
544
566
  final List<Widget> children;
545
567
  final double minItemWidth;
546
- const _ResponsiveGrid({
547
- required this.children,
548
- this.minItemWidth = 240,
549
- });
568
+ const _ResponsiveGrid({required this.children, this.minItemWidth = 240});
550
569
 
551
570
  @override
552
571
  Widget build(BuildContext context) {
@@ -559,8 +578,9 @@ class _ResponsiveGrid extends StatelessWidget {
559
578
  int cols = ((maxW + gap) / (minItemWidth + gap)).floor();
560
579
  cols = cols.clamp(1, maxCols);
561
580
  if (cols > children.length) cols = children.length;
562
- final double itemW =
563
- cols <= 1 ? maxW : (maxW - gap * (cols - 1)) / cols;
581
+ final double itemW = cols <= 1
582
+ ? maxW
583
+ : (maxW - gap * (cols - 1)) / cols;
564
584
  return Wrap(
565
585
  spacing: gap,
566
586
  runSpacing: gap,
@@ -608,30 +628,13 @@ class _OverviewTab extends ConsumerWidget {
608
628
 
609
629
  return _TabScroll(
610
630
  children: [
611
- _GroupLabel(ov.section),
612
- _ResponsiveGrid(
613
- minItemWidth: 200,
614
- children: [
615
- _StatCard(
616
- icon: KasyIcons.monitor,
617
- tone: context.colors.primary,
618
- value: 'Firebase',
619
- label: ov.backend,
620
- ),
621
- // Feature-request count reads the server — admins only.
622
- if (isAdmin)
623
- _StatCard(
624
- icon: KasyIcons.voteUp,
625
- tone: context.colors.primary,
626
- value: ref.watch(_adminRequestsProvider).maybeWhen(
627
- data: (l) => '${l.length}',
628
- orElse: () => '…',
629
- ),
630
- label: ov.requests_metric,
631
- ),
632
- ],
633
- ),
634
- 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
+ ],
635
638
  _GroupLabel(ov.session_title),
636
639
  _CardShell(
637
640
  padding: const EdgeInsets.symmetric(
@@ -640,6 +643,8 @@ class _OverviewTab extends ConsumerWidget {
640
643
  ),
641
644
  child: Column(
642
645
  children: [
646
+ _InfoRow(label: ov.backend, value: 'Firebase'),
647
+ const SettingsDivider(),
643
648
  _InfoRow(
644
649
  label: ov.account,
645
650
  value: account,
@@ -652,7 +657,9 @@ class _OverviewTab extends ConsumerWidget {
652
657
  trailing: _CopyButton(
653
658
  onTap: () {
654
659
  Clipboard.setData(ClipboardData(text: uid));
655
- ref.read(toastProvider).alert(
660
+ ref
661
+ .read(toastProvider)
662
+ .alert(
656
663
  title: t.common.copied,
657
664
  text: t.settings.admin.user_id_copied,
658
665
  );
@@ -706,18 +713,634 @@ class _OverviewTab extends ConsumerWidget {
706
713
  color: context.colors.muted,
707
714
  ),
708
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
+ ),
709
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),
710
1262
  Text(
711
- ov.debug_note,
1263
+ label,
712
1264
  style: context.textTheme.bodySmall?.copyWith(
713
1265
  color: context.colors.muted,
714
1266
  ),
715
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
+ ),
716
1276
  ],
717
1277
  );
718
1278
  }
719
1279
  }
720
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
+
721
1344
  class _InfoRow extends StatelessWidget {
722
1345
  final String label;
723
1346
  final String value;
@@ -800,16 +1423,13 @@ class _CopyButton extends StatelessWidget {
800
1423
 
801
1424
  @override
802
1425
  Widget build(BuildContext context) {
803
- return InkWell(
1426
+ return KasyHover(
804
1427
  onTap: onTap,
1428
+ focusable: true,
805
1429
  borderRadius: BorderRadius.circular(KasyRadius.sm),
806
1430
  child: Padding(
807
1431
  padding: const EdgeInsets.all(6),
808
- child: Icon(
809
- KasyIcons.copy,
810
- size: 16,
811
- color: context.colors.primary,
812
- ),
1432
+ child: Icon(KasyIcons.copy, size: 16, color: context.colors.primary),
813
1433
  ),
814
1434
  );
815
1435
  }
@@ -901,8 +1521,9 @@ class _RequestsTab extends ConsumerWidget {
901
1521
  message: t.admin_console.requires_admin,
902
1522
  );
903
1523
  }
904
- final AsyncValue<List<FeatureRequestEntity>> async =
905
- ref.watch(_adminRequestsProvider);
1524
+ final AsyncValue<List<FeatureRequestEntity>> async = ref.watch(
1525
+ _adminRequestsProvider,
1526
+ );
906
1527
  return async.when(
907
1528
  loading: () => const Center(child: CircularProgressIndicator.adaptive()),
908
1529
  error: (_, _) => _EmptyState(
@@ -918,9 +1539,12 @@ class _RequestsTab extends ConsumerWidget {
918
1539
  message: r.empty,
919
1540
  );
920
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));
921
1545
  return _TabScroll(
922
1546
  children: [
923
- for (final req in list) ...[
1547
+ for (final req in sorted) ...[
924
1548
  _RequestCard(req),
925
1549
  const SizedBox(height: KasySpacing.md),
926
1550
  ],
@@ -1010,7 +1634,9 @@ class _RequestCard extends ConsumerWidget {
1010
1634
  .setActive(req.id!, v);
1011
1635
  ref.invalidate(_adminRequestsProvider);
1012
1636
  if (context.mounted) {
1013
- 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);
1014
1640
  }
1015
1641
  },
1016
1642
  ),
@@ -1018,7 +1644,9 @@ class _RequestCard extends ConsumerWidget {
1018
1644
  const Spacer(),
1019
1645
  KasyButton(
1020
1646
  label: r.edit,
1021
- variant: KasyButtonVariant.soft,
1647
+ variant: KasyButtonVariant.outline,
1648
+ size: KasyButtonSize.small,
1649
+ icon: KasyIcons.language,
1022
1650
  onPressed: () => _openRequestEditor(context, req),
1023
1651
  ),
1024
1652
  ],
@@ -1066,7 +1694,10 @@ class _VotesChip extends StatelessWidget {
1066
1694
  }
1067
1695
  }
1068
1696
 
1069
- Future<void> _openRequestEditor(BuildContext context, FeatureRequestEntity req) {
1697
+ Future<void> _openRequestEditor(
1698
+ BuildContext context,
1699
+ FeatureRequestEntity req,
1700
+ ) {
1070
1701
  return showKasyBlurBottomSheet<void>(
1071
1702
  context: context,
1072
1703
  builder: (_) => _RequestEditorSheet(req: req),
@@ -1125,7 +1756,9 @@ class _RequestEditorSheetState extends ConsumerState<_RequestEditorSheet> {
1125
1756
  final r = t.admin_console.requests;
1126
1757
  setState(() => _saving = true);
1127
1758
  try {
1128
- await ref.read(featureRequestApiProvider).updateTexts(
1759
+ await ref
1760
+ .read(featureRequestApiProvider)
1761
+ .updateTexts(
1129
1762
  id: widget.req.id!,
1130
1763
  title: {for (final l in _langs) l: _title[l]!.text.trim()},
1131
1764
  description: {for (final l in _langs) l: _desc[l]!.text.trim()},
@@ -1199,6 +1832,13 @@ class _RequestEditorSheetState extends ConsumerState<_RequestEditorSheet> {
1199
1832
  /// list pattern of the Settings screen (no busy card grid). Shared by the Tools
1200
1833
  /// sections.
1201
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
+ ),
1202
1842
  child: Column(
1203
1843
  mainAxisSize: MainAxisSize.min,
1204
1844
  children: [
@@ -1210,30 +1850,162 @@ Widget _groupCard(List<Widget> rows) => _CardShell(
1210
1850
  ),
1211
1851
  );
1212
1852
 
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.
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.
1215
1857
  class _PaywallsTab extends StatelessWidget {
1216
1858
  const _PaywallsTab();
1217
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
+ ];
1867
+
1218
1868
  @override
1219
1869
  Widget build(BuildContext context) {
1220
1870
  final admin = t.settings.admin;
1871
+ final pw = t.admin_console.paywalls;
1221
1872
  return _TabScroll(
1222
1873
  children: [
1223
1874
  _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)),
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,
1230
1885
  ),
1231
- ]),
1886
+ ),
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
+ ],
1232
1894
  ],
1233
1895
  );
1234
1896
  }
1235
1897
  }
1236
1898
 
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
+ }
1909
+
1910
+ class _PaywallCard extends ConsumerWidget {
1911
+ final PaywallFactory paywall;
1912
+ const _PaywallCard({required this.paywall});
1913
+
1914
+ @override
1915
+ Widget build(BuildContext context, WidgetRef ref) {
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);
1961
+ },
1962
+ ),
1963
+ ],
1964
+ ),
1965
+ ),
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
+ ],
2003
+ ),
2004
+ ),
2005
+ );
2006
+ }
2007
+ }
2008
+
1237
2009
  /// The UI-kit catalog as a console section (debug only). Width-capped + centered
1238
2010
  /// to the same [_contentMaxWidth] as every other section (Paywalls, Overview…),
1239
2011
  /// so the console reads as one consistent column instead of the catalog
@@ -1269,14 +2041,11 @@ class _DebugTab extends ConsumerWidget {
1269
2041
  title: admin.copy_user_id,
1270
2042
  onTap: () {
1271
2043
  Clipboard.setData(
1272
- ClipboardData(
1273
- text: userState.user.idOrNull ?? 'no-id (guest)',
1274
- ),
2044
+ ClipboardData(text: userState.user.idOrNull ?? 'no-id (guest)'),
1275
2045
  );
1276
- ref.read(toastProvider).alert(
1277
- title: t.common.copied,
1278
- text: admin.user_id_copied,
1279
- );
2046
+ ref
2047
+ .read(toastProvider)
2048
+ .alert(title: t.common.copied, text: admin.user_id_copied);
1280
2049
  },
1281
2050
  ),
1282
2051
  SettingsTile(
@@ -1284,7 +2053,9 @@ class _DebugTab extends ConsumerWidget {
1284
2053
  title: admin.copy_fcm_token,
1285
2054
  onTap: () async {
1286
2055
  if (kIsWeb) {
1287
- ref.read(toastProvider).alert(
2056
+ ref
2057
+ .read(toastProvider)
2058
+ .alert(
1288
2059
  title: t.common.native_only_title,
1289
2060
  text: admin.native_only,
1290
2061
  );
@@ -1292,25 +2063,28 @@ class _DebugTab extends ConsumerWidget {
1292
2063
  }
1293
2064
  final token = await FirebaseMessaging.instance.getToken();
1294
2065
  if (token == null) {
1295
- ref.read(toastProvider).alert(
2066
+ ref
2067
+ .read(toastProvider)
2068
+ .alert(
1296
2069
  title: t.common.unavailable,
1297
2070
  text: admin.fcm_token_unavailable,
1298
2071
  );
1299
2072
  return;
1300
2073
  }
1301
2074
  await Clipboard.setData(ClipboardData(text: token));
1302
- ref.read(toastProvider).alert(
1303
- title: t.common.copied,
1304
- text: admin.fcm_token_copied,
1305
- );
2075
+ ref
2076
+ .read(toastProvider)
2077
+ .alert(title: t.common.copied, text: admin.fcm_token_copied);
1306
2078
  },
1307
2079
  ),
1308
2080
  SettingsTile(
1309
- icon: KasyIcons.notificationActive,
2081
+ icon: KasyIcons.notification,
1310
2082
  title: admin.ask_notification,
1311
2083
  onTap: () {
1312
2084
  if (kIsWeb) {
1313
- ref.read(toastProvider).alert(
2085
+ ref
2086
+ .read(toastProvider)
2087
+ .alert(
1314
2088
  title: t.common.native_only_title,
1315
2089
  text: admin.native_only,
1316
2090
  );
@@ -1349,7 +2123,11 @@ class _DebugTab extends ConsumerWidget {
1349
2123
  SettingsTile(
1350
2124
  icon: KasyIcons.check,
1351
2125
  title: admin.test_onboarding,
1352
- onTap: () => ref.read(goRouterProvider).go('/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'),
1353
2131
  ),
1354
2132
  SettingsTile(
1355
2133
  icon: KasyIcons.star,
@@ -1358,12 +2136,16 @@ class _DebugTab extends ConsumerWidget {
1358
2136
  // the store action no-ops there.
1359
2137
  onTap: () => showReviewDialog(context, ref, force: true),
1360
2138
  ),
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
- ),
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
+ ),
1367
2149
  ]),
1368
2150
 
1369
2151
  const SizedBox(height: KasySpacing.lg),
@@ -1378,7 +2160,9 @@ class _DebugTab extends ConsumerWidget {
1378
2160
  // Local notifications don't fire on web — tell the user instead
1379
2161
  // of doing nothing when tapped.
1380
2162
  if (kIsWeb) {
1381
- ref.read(toastProvider).alert(
2163
+ ref
2164
+ .read(toastProvider)
2165
+ .alert(
1382
2166
  title: t.common.native_only_title,
1383
2167
  text: admin.native_only,
1384
2168
  );