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