kasy-cli 1.38.0 → 1.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/scaffold/CHANGELOG.json +23 -0
- package/lib/scaffold/backends/api/patch/README.md +15 -0
- package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
- package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
- package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
- package/lib/scaffold/backends/patch-base-hashes.json +6 -6
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
- package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
- package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
- package/lib/scaffold/shared/generator-utils.js +12 -6
- package/package.json +1 -1
- package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
- package/templates/firebase/AGENTS.md +2 -2
- package/templates/firebase/DESIGN_SYSTEM.md +23 -8
- package/templates/firebase/assets/icons/apple_black.svg +3 -0
- package/templates/firebase/assets/icons/apple_white.svg +4 -0
- package/templates/firebase/assets/icons/facebook.svg +49 -0
- package/templates/firebase/assets/icons/google.svg +1 -0
- package/templates/firebase/functions/src/admin/functions.ts +2 -0
- package/templates/firebase/functions/src/authentication/functions.ts +13 -7
- package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
- package/templates/firebase/lib/components/components.dart +5 -2
- package/templates/firebase/lib/components/kasy_app_bar.dart +325 -15
- package/templates/firebase/lib/components/kasy_card.dart +4 -0
- package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
- package/templates/firebase/lib/components/kasy_sidebar.dart +18 -6
- package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
- package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +27 -18
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +34 -16
- package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
- package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
- package/templates/firebase/lib/core/data/api/user_api.dart +11 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +95 -30
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
- package/templates/firebase/lib/core/states/logout_action.dart +11 -1
- package/templates/firebase/lib/core/states/user_state_notifier.dart +28 -1
- package/templates/firebase/lib/core/theme/texts.dart +21 -6
- package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +51 -19
- package/templates/firebase/lib/core/web_viewport_scale.dart +66 -36
- package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
- package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
- package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
- package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
- package/templates/firebase/lib/features/home/home_components_page.dart +253 -125
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +263 -59
- package/templates/firebase/lib/features/home/home_feed.dart +2 -2
- package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
- package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +111 -57
- package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -4
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
- package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
- package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +2 -2
- package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
- package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
- package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
- package/templates/firebase/lib/features/settings/settings_page.dart +53 -32
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_home_widgets.dart +4 -0
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +895 -111
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
- package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +171 -41
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +1 -1
- package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +48 -47
- package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
- package/templates/firebase/lib/i18n/en.i18n.json +753 -712
- package/templates/firebase/lib/i18n/es.i18n.json +753 -712
- package/templates/firebase/lib/i18n/pt.i18n.json +753 -712
- package/templates/firebase/lib/main.dart +20 -7
- package/templates/firebase/lib/router.dart +32 -26
- package/templates/firebase/pubspec.yaml +2 -1
- package/templates/firebase/test/admin_shell_chrome_test.dart +11 -5
- package/templates/firebase/test/app_bar_config_test.dart +70 -0
- package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
- package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
- package/templates/firebase/tool/design_check.dart +9 -0
- package/templates/firebase/assets/icons/apple.png +0 -0
- package/templates/firebase/assets/icons/facebook.png +0 -0
- package/templates/firebase/assets/icons/google.png +0 -0
- package/templates/firebase/assets/icons/google_play_games.png +0 -0
- package/templates/firebase/lib/components/kasy_web_header.dart +0 -218
- package/templates/firebase/lib/core/chrome/web_header_scope.dart +0 -20
- package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
- package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
- package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
- package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -179
- 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]).
|
|
51
|
-
///
|
|
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)
|
|
86
|
-
///
|
|
87
|
-
///
|
|
88
|
-
///
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
),
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
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
|
|
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 —
|
|
1214
|
-
///
|
|
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
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
|
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
|
|
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
|
|
1303
|
-
|
|
1304
|
-
|
|
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.
|
|
2081
|
+
icon: KasyIcons.notification,
|
|
1310
2082
|
title: admin.ask_notification,
|
|
1311
2083
|
onTap: () {
|
|
1312
2084
|
if (kIsWeb) {
|
|
1313
|
-
ref
|
|
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
|
-
|
|
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
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
|
2163
|
+
ref
|
|
2164
|
+
.read(toastProvider)
|
|
2165
|
+
.alert(
|
|
1382
2166
|
title: t.common.native_only_title,
|
|
1383
2167
|
text: admin.native_only,
|
|
1384
2168
|
);
|