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