kasy-cli 1.37.1 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/lib/scaffold/CHANGELOG.json +23 -0
  2. package/lib/scaffold/backends/api/patch/README.md +15 -0
  3. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  4. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
  5. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  6. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  7. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  8. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  10. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  11. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  12. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  13. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  15. package/lib/scaffold/shared/generator-utils.js +12 -6
  16. package/package.json +1 -1
  17. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  18. package/templates/firebase/AGENTS.md +7 -1
  19. package/templates/firebase/DESIGN_SYSTEM.md +35 -8
  20. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  21. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  22. package/templates/firebase/assets/icons/facebook.svg +49 -0
  23. package/templates/firebase/assets/icons/google.svg +1 -0
  24. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  25. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  26. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  27. package/templates/firebase/lib/components/components.dart +1 -1
  28. package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
  29. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  30. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  32. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
  34. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  35. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  36. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
  37. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  38. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
  39. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  40. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  41. package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
  42. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  43. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  44. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  45. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  46. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  47. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
  48. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  49. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  50. package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
  51. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  52. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  53. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  54. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  55. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  56. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  57. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  58. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
  59. package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
  60. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  61. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  62. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  63. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  66. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  67. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  68. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  69. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  70. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  71. package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
  72. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
  73. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  74. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  75. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  76. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
  77. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  78. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
  79. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  80. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  81. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  82. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  83. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  84. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  85. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  86. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  87. package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
  88. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
  89. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  90. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  91. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  92. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
  93. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
  94. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  95. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
  96. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  97. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  98. package/templates/firebase/lib/i18n/en.i18n.json +749 -698
  99. package/templates/firebase/lib/i18n/es.i18n.json +749 -698
  100. package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
  101. package/templates/firebase/lib/main.dart +20 -7
  102. package/templates/firebase/lib/router.dart +70 -46
  103. package/templates/firebase/pubspec.yaml +2 -1
  104. package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
  105. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  106. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  107. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  108. package/templates/firebase/tool/design_check.dart +9 -0
  109. package/templates/firebase/assets/icons/apple.png +0 -0
  110. package/templates/firebase/assets/icons/facebook.png +0 -0
  111. package/templates/firebase/assets/icons/google.png +0 -0
  112. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  113. package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
  114. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  115. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  116. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  117. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  118. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
  119. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
  120. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -7,7 +7,7 @@ import 'package:kasy_kit/components/kasy_sidebar.dart';
7
7
  import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
8
8
  import 'package:kasy_kit/core/bottom_menu/bottom_router.dart';
9
9
  import 'package:kasy_kit/core/bottom_menu/kasy_bottom_bar_factory.dart';
10
- import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
10
+ import 'package:kasy_kit/core/bottom_menu/sidebar_focus.dart';
11
11
  import 'package:kasy_kit/core/bottom_menu/web_url.dart';
12
12
  import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
13
13
  import 'package:kasy_kit/core/data/models/user.dart';
@@ -67,7 +67,7 @@ class BottomMenu extends StatelessWidget {
67
67
  bart.CustomSideBarOptions connectedSidebar() => bart.CustomSideBarOptions(
68
68
  sideBarBuilder: (routes, onTap, current) => FocusTraversalOrder(
69
69
  order: const NumericFocusOrder(1),
70
- child: _FocusableSidebar(
70
+ child: KasyFocusableSidebar(
71
71
  currentItem: current,
72
72
  child: Consumer(
73
73
  builder: (context, ref, _) {
@@ -125,26 +125,35 @@ class BottomMenu extends StatelessWidget {
125
125
  },
126
126
  child: ResponsiveLayout(
127
127
  small: Consumer(
128
- builder: (context, ref, _) {
128
+ // The scaffold is passed as `child` so it is built ONCE and reused
129
+ // across rebuilds of this Consumer. Toggling the setting below must
130
+ // NOT rebuild BartScaffold: if it did, Bart's MenuRouter (an
131
+ // InheritedWidget) re-runs its constructor and snaps the highlighted
132
+ // tab back to `initialRoute` — a value captured at mount, so possibly
133
+ // Home — even while the nested navigator still shows the current tab
134
+ // (the bottom bar marked "Início" while sitting on Settings).
135
+ child: NotificationListener<ScrollUpdateNotification>(
136
+ onNotification: (notification) {
137
+ KasyChromeVisibility.instance.handleScrollUpdate(notification);
138
+ return false; // let the notification keep bubbling
139
+ },
140
+ child: bart.BartScaffold(
141
+ routesBuilder: subRoutes,
142
+ bottomBar: kasyPaddedSurfaceBottomBar(),
143
+ initialRoute: resolvedInitialRoute,
144
+ showBottomBarOnStart: showBottomBarOnStart,
145
+ scaffoldOptions: scaffoldOptions,
146
+ onRouteChanged: _rememberActiveTab,
147
+ ),
148
+ ),
149
+ builder: (context, ref, child) {
129
150
  // Watching the provider here keeps the persisted on/off setting in
130
- // sync with the controller, and scopes scroll tracking to the
131
- // mobile shell only (detail screens push on the root navigator, so
132
- // their scrolls never reach this listener).
151
+ // sync with the controller (the notifier writes the effective value
152
+ // into KasyChromeVisibility.instance.enabled), and scopes it to the
153
+ // mobile shell only. Returning the cached `child` keeps the scaffold
154
+ // out of this rebuild.
133
155
  ref.watch(hideChromeOnScrollProvider);
134
- return NotificationListener<ScrollUpdateNotification>(
135
- onNotification: (notification) {
136
- KasyChromeVisibility.instance.handleScrollUpdate(notification);
137
- return false; // let the notification keep bubbling
138
- },
139
- child: bart.BartScaffold(
140
- routesBuilder: subRoutes,
141
- bottomBar: kasyPaddedSurfaceBottomBar(),
142
- initialRoute: resolvedInitialRoute,
143
- showBottomBarOnStart: showBottomBarOnStart,
144
- scaffoldOptions: scaffoldOptions,
145
- onRouteChanged: _rememberActiveTab,
146
- ),
147
- );
156
+ return child!;
148
157
  },
149
158
  ),
150
159
  medium: connectedScaffold(),
@@ -228,214 +237,3 @@ class BottomMenu extends StatelessWidget {
228
237
  return segments.length < 2;
229
238
  }
230
239
  }
231
-
232
- /// Keeps the initial keyboard Tab focus anchored on the sidebar — on every
233
- /// screen, like Stripe/Linear.
234
- ///
235
- /// Why this exists: Bart renders each page inside a nested [Navigator]
236
- /// (see bart's nested_navigator.dart), which has its OWN FocusScope and claims
237
- /// the primary focus the moment a route mounts. A plain `autofocus` on a sidebar
238
- /// item loses that race — the Navigator overwrites it in the same frame.
239
- ///
240
- /// The fix is a tiny non-traversable [Focus] anchor inside the sidebar. A
241
- /// post-frame callback (runs AFTER the Navigator has claimed focus) moves focus
242
- /// to that anchor, pulling the primary focus out of the Navigator's scope and
243
- /// back onto the sidebar. Because the anchor sets `skipTraversal: true`, it is
244
- /// skipped by Tab, so the very first Tab lands on the first real sidebar item
245
- /// and then flows on to the header and content — nothing is trapped. (A
246
- /// [FocusScope] would have worked for the anchoring, but it adds a traversal
247
- /// boundary that wraps Tab back to the sidebar's start instead of moving on to
248
- /// the content, which is the opposite of what we want.)
249
- ///
250
- /// It re-anchors whenever [currentItem] changes (a tab navigation) so a fresh
251
- /// screen also starts at the sidebar. The ring still only paints during keyboard
252
- /// navigation, so this is invisible to mouse/touch users.
253
- class _FocusableSidebar extends StatefulWidget {
254
- final Widget child;
255
- final ValueNotifier<int> currentItem;
256
-
257
- const _FocusableSidebar({required this.child, required this.currentItem});
258
-
259
- @override
260
- State<_FocusableSidebar> createState() => _FocusableSidebarState();
261
- }
262
-
263
- class _FocusableSidebarState extends State<_FocusableSidebar> {
264
- final FocusNode _anchor = FocusNode(
265
- debugLabel: 'sidebarFocusAnchor',
266
- skipTraversal: true,
267
- );
268
-
269
- @override
270
- void initState() {
271
- super.initState();
272
- widget.currentItem.addListener(_anchorFocus);
273
- _anchorFocus();
274
- }
275
-
276
- @override
277
- void didUpdateWidget(_FocusableSidebar oldWidget) {
278
- super.didUpdateWidget(oldWidget);
279
- if (oldWidget.currentItem != widget.currentItem) {
280
- oldWidget.currentItem.removeListener(_anchorFocus);
281
- widget.currentItem.addListener(_anchorFocus);
282
- }
283
- }
284
-
285
- // Defer to after the frame so we win the race against the nested Navigator,
286
- // which claims focus for its own scope while the route is mounting.
287
- void _anchorFocus() {
288
- WidgetsBinding.instance.addPostFrameCallback((_) {
289
- if (mounted) _anchor.requestFocus();
290
- });
291
- }
292
-
293
- @override
294
- void dispose() {
295
- widget.currentItem.removeListener(_anchorFocus);
296
- _anchor.dispose();
297
- super.dispose();
298
- }
299
-
300
- // "Skip to content" jumps focus straight to the FIRST real control in the
301
- // routed content. The content target is a skipTraversal region (tabindex=-1
302
- // style), so stepping once past it lands on a visible control immediately,
303
- // instead of focusing the invisible region and needing a second Tab. Falls
304
- // back to the region itself if the page has no focusable control.
305
- void _skipToContent() {
306
- final FocusNode? target = kasyContentFocusTarget;
307
- if (target == null) return;
308
- if (!target.nextFocus()) target.requestFocus();
309
- }
310
-
311
- @override
312
- Widget build(BuildContext context) {
313
- // Reading-order group (NOT an ordered policy): this is the exact structure
314
- // that made the anchor hold the initial focus. The anchor sits at (0,0) and
315
- // is skipped by Tab; the skip link is positioned at the very top, so reading
316
- // order makes it the FIRST Tab stop, then the sidebar items, then (via the
317
- // scaffold) the header and content. Swapping in an OrderedTraversalPolicy
318
- // here broke the anchor, so we keep reading order and rely on position.
319
- return FocusTraversalGroup(
320
- child: Stack(
321
- clipBehavior: Clip.none,
322
- children: [
323
- widget.child,
324
- // Zero-size sibling; only holds the initial keyboard focus.
325
- Focus(focusNode: _anchor, child: const SizedBox.shrink()),
326
- // Topmost on screen, so reading order makes it the first Tab stop.
327
- Positioned(
328
- top: KasySpacing.sm,
329
- left: KasySpacing.sm,
330
- child: _SkipToContentLink(onSkip: _skipToContent),
331
- ),
332
- ],
333
- ),
334
- );
335
- }
336
- }
337
-
338
- /// The "skip to content" link (WCAG 2.4.1 "Bypass Blocks"). It is the first Tab
339
- /// stop on every screen: pressing Tab once reveals it above the sidebar, Enter
340
- /// jumps focus into the content, and pressing Tab again moves on to the sidebar.
341
- /// It only paints while focused via the keyboard, so pointer/touch users never
342
- /// see it. Mirrors the pattern used by Stripe, GitHub, etc.
343
- class _SkipToContentLink extends StatefulWidget {
344
- final VoidCallback onSkip;
345
-
346
- const _SkipToContentLink({required this.onSkip});
347
-
348
- @override
349
- State<_SkipToContentLink> createState() => _SkipToContentLinkState();
350
- }
351
-
352
- class _SkipToContentLinkState extends State<_SkipToContentLink> {
353
- bool _show = false;
354
- final OverlayPortalController _overlay = OverlayPortalController();
355
- final LayerLink _link = LayerLink();
356
-
357
- static const Map<ShortcutActivator, Intent> _shortcuts =
358
- <ShortcutActivator, Intent>{
359
- SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
360
- SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(),
361
- SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
362
- };
363
-
364
- void _setShown(bool show) {
365
- if (!mounted || show == _show) return;
366
- setState(() => _show = show);
367
- show ? _overlay.show() : _overlay.hide();
368
- }
369
-
370
- @override
371
- Widget build(BuildContext context) {
372
- // Colours/text are resolved here, in the sidebar's context, and passed into
373
- // the overlay below — an overlay context doesn't reliably inherit the app
374
- // theme (same reason the collapsed-rail tooltip in this file does it).
375
- final KasyColors c = context.colors;
376
- final String label = context.t.navigation.skip_to_content;
377
- final TextStyle? labelStyle = context.textTheme.bodyMedium?.copyWith(
378
- color: c.onSurface,
379
- fontWeight: FontWeight.w600,
380
- );
381
-
382
- // The focusable node lives here so the link stays the first Tab stop, but
383
- // the visible card is painted in the root Overlay, anchored to this spot via
384
- // [_link]. The collapsed sidebar is narrower than the card, so an inline
385
- // card would be clipped at the rail's edge; the overlay floats above
386
- // everything and is never clipped. The inline child is zero-size, so the
387
- // detector also no longer overflows onto the panel toggle (dismiss-on-click
388
- // is handled globally by FocusVisibility).
389
- return FocusableActionDetector(
390
- shortcuts: _shortcuts,
391
- actions: <Type, Action<Intent>>{
392
- ActivateIntent: CallbackAction<ActivateIntent>(
393
- onInvoke: (_) {
394
- widget.onSkip();
395
- return null;
396
- },
397
- ),
398
- },
399
- onShowFocusHighlight: _setShown,
400
- child: CompositedTransformTarget(
401
- link: _link,
402
- child: OverlayPortal(
403
- controller: _overlay,
404
- overlayChildBuilder: (_) => CompositedTransformFollower(
405
- link: _link,
406
- showWhenUnlinked: false,
407
- child: Align(
408
- alignment: Alignment.topLeft,
409
- child: Material(
410
- color: Colors.transparent,
411
- child: GestureDetector(
412
- onTap: widget.onSkip,
413
- child: Container(
414
- padding: const EdgeInsets.symmetric(
415
- horizontal: KasySpacing.md,
416
- vertical: KasySpacing.sm,
417
- ),
418
- decoration: BoxDecoration(
419
- color: c.surface,
420
- borderRadius: BorderRadius.circular(KasyRadius.md),
421
- border: Border.all(color: c.primary, width: 1.5),
422
- boxShadow: <BoxShadow>[
423
- BoxShadow(
424
- color: c.onSurface.withValues(alpha: 0.18),
425
- blurRadius: 16,
426
- offset: const Offset(0, 4),
427
- ),
428
- ],
429
- ),
430
- child: Text(label, style: labelStyle),
431
- ),
432
- ),
433
- ),
434
- ),
435
- ),
436
- child: const SizedBox.shrink(),
437
- ),
438
- ),
439
- );
440
- }
441
- }
@@ -0,0 +1,224 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:flutter/services.dart';
3
+ import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
4
+ import 'package:kasy_kit/core/theme/theme.dart';
5
+ import 'package:kasy_kit/i18n/translations.g.dart';
6
+
7
+ /// Keeps the initial keyboard Tab focus anchored on the sidebar — on every
8
+ /// screen, like Stripe/Linear — and hosts the "skip to content" link.
9
+ ///
10
+ /// Why this exists: a page rendered inside a nested [Navigator] has its OWN
11
+ /// FocusScope and claims the primary focus the moment a route mounts. A plain
12
+ /// `autofocus` on a sidebar item loses that race — the Navigator overwrites it in
13
+ /// the same frame.
14
+ ///
15
+ /// The fix is a tiny non-traversable [Focus] anchor inside the sidebar. A
16
+ /// post-frame callback (runs AFTER the Navigator has claimed focus) moves focus
17
+ /// to that anchor, pulling the primary focus out of the Navigator's scope and
18
+ /// back onto the sidebar. Because the anchor sets `skipTraversal: true`, it is
19
+ /// skipped by Tab, so the very first Tab lands on the first real sidebar item and
20
+ /// then flows on to the header and content — nothing is trapped.
21
+ ///
22
+ /// Re-anchors whenever [currentItem] fires (a tab/section navigation) so a fresh
23
+ /// screen also starts at the sidebar. When [currentItem] is null it anchors once,
24
+ /// on mount. The ring only paints during keyboard navigation, so this is
25
+ /// invisible to mouse/touch users.
26
+ ///
27
+ /// Shared by the app shell (bottom_menu.dart) and the admin console, so both get
28
+ /// the exact same keyboard behaviour from one implementation.
29
+ class KasyFocusableSidebar extends StatefulWidget {
30
+ final Widget child;
31
+
32
+ /// Fires on navigation to re-anchor focus to the sidebar. Optional: when null,
33
+ /// focus is anchored only once, on mount.
34
+ final Listenable? currentItem;
35
+
36
+ const KasyFocusableSidebar({
37
+ super.key,
38
+ required this.child,
39
+ this.currentItem,
40
+ });
41
+
42
+ @override
43
+ State<KasyFocusableSidebar> createState() => _KasyFocusableSidebarState();
44
+ }
45
+
46
+ class _KasyFocusableSidebarState extends State<KasyFocusableSidebar> {
47
+ final FocusNode _anchor = FocusNode(
48
+ debugLabel: 'sidebarFocusAnchor',
49
+ skipTraversal: true,
50
+ );
51
+
52
+ @override
53
+ void initState() {
54
+ super.initState();
55
+ widget.currentItem?.addListener(_anchorFocus);
56
+ _anchorFocus();
57
+ }
58
+
59
+ @override
60
+ void didUpdateWidget(KasyFocusableSidebar oldWidget) {
61
+ super.didUpdateWidget(oldWidget);
62
+ if (oldWidget.currentItem != widget.currentItem) {
63
+ oldWidget.currentItem?.removeListener(_anchorFocus);
64
+ widget.currentItem?.addListener(_anchorFocus);
65
+ }
66
+ }
67
+
68
+ // Defer to after the frame so we win the race against the nested Navigator,
69
+ // which claims focus for its own scope while the route is mounting.
70
+ void _anchorFocus() {
71
+ WidgetsBinding.instance.addPostFrameCallback((_) {
72
+ if (mounted) _anchor.requestFocus();
73
+ });
74
+ }
75
+
76
+ @override
77
+ void dispose() {
78
+ widget.currentItem?.removeListener(_anchorFocus);
79
+ _anchor.dispose();
80
+ super.dispose();
81
+ }
82
+
83
+ // "Skip to content" jumps focus straight to the FIRST real control in the
84
+ // routed content. The content target is a skipTraversal region (tabindex=-1
85
+ // style), so stepping once past it lands on a visible control immediately,
86
+ // instead of focusing the invisible region and needing a second Tab. Falls
87
+ // back to the region itself if the page has no focusable control.
88
+ void _skipToContent() {
89
+ final FocusNode? target = kasyContentFocusTarget;
90
+ if (target == null) return;
91
+ if (!target.nextFocus()) target.requestFocus();
92
+ }
93
+
94
+ @override
95
+ Widget build(BuildContext context) {
96
+ // Reading-order group (NOT an ordered policy): this is the exact structure
97
+ // that made the anchor hold the initial focus. The anchor sits at (0,0) and
98
+ // is skipped by Tab; the skip link is positioned at the very top, so reading
99
+ // order makes it the FIRST Tab stop, then the sidebar items, then (via the
100
+ // scaffold) the header and content. Swapping in an OrderedTraversalPolicy
101
+ // here broke the anchor, so we keep reading order and rely on position.
102
+ return FocusTraversalGroup(
103
+ child: Stack(
104
+ clipBehavior: Clip.none,
105
+ children: [
106
+ widget.child,
107
+ // Zero-size sibling; only holds the initial keyboard focus.
108
+ Focus(focusNode: _anchor, child: const SizedBox.shrink()),
109
+ // Topmost on screen, so reading order makes it the first Tab stop.
110
+ Positioned(
111
+ top: KasySpacing.sm,
112
+ left: KasySpacing.sm,
113
+ child: _SkipToContentLink(onSkip: _skipToContent),
114
+ ),
115
+ ],
116
+ ),
117
+ );
118
+ }
119
+ }
120
+
121
+ /// The "skip to content" link (WCAG 2.4.1 "Bypass Blocks"). It is the first Tab
122
+ /// stop on every screen: pressing Tab once reveals it above the sidebar, Enter
123
+ /// jumps focus into the content, and pressing Tab again moves on to the sidebar.
124
+ /// It only paints while focused via the keyboard, so pointer/touch users never
125
+ /// see it. Mirrors the pattern used by Stripe, GitHub, etc.
126
+ class _SkipToContentLink extends StatefulWidget {
127
+ final VoidCallback onSkip;
128
+
129
+ const _SkipToContentLink({required this.onSkip});
130
+
131
+ @override
132
+ State<_SkipToContentLink> createState() => _SkipToContentLinkState();
133
+ }
134
+
135
+ class _SkipToContentLinkState extends State<_SkipToContentLink> {
136
+ bool _show = false;
137
+ final OverlayPortalController _overlay = OverlayPortalController();
138
+ final LayerLink _link = LayerLink();
139
+
140
+ static const Map<ShortcutActivator, Intent> _shortcuts =
141
+ <ShortcutActivator, Intent>{
142
+ SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
143
+ SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(),
144
+ SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
145
+ };
146
+
147
+ void _setShown(bool show) {
148
+ if (!mounted || show == _show) return;
149
+ setState(() => _show = show);
150
+ show ? _overlay.show() : _overlay.hide();
151
+ }
152
+
153
+ @override
154
+ Widget build(BuildContext context) {
155
+ // Colours/text are resolved here, in the sidebar's context, and passed into
156
+ // the overlay below — an overlay context doesn't reliably inherit the app
157
+ // theme (same reason the collapsed-rail tooltip does it).
158
+ final KasyColors c = context.colors;
159
+ final String label = context.t.navigation.skip_to_content;
160
+ final TextStyle? labelStyle = context.textTheme.bodyMedium?.copyWith(
161
+ color: c.onSurface,
162
+ fontWeight: FontWeight.w600,
163
+ );
164
+
165
+ // The focusable node lives here so the link stays the first Tab stop, but
166
+ // the visible card is painted in the root Overlay, anchored to this spot via
167
+ // [_link]. The collapsed sidebar is narrower than the card, so an inline
168
+ // card would be clipped at the rail's edge; the overlay floats above
169
+ // everything and is never clipped. The inline child is zero-size, so the
170
+ // detector also no longer overflows onto the panel toggle (dismiss-on-click
171
+ // is handled globally by FocusVisibility).
172
+ return FocusableActionDetector(
173
+ shortcuts: _shortcuts,
174
+ actions: <Type, Action<Intent>>{
175
+ ActivateIntent: CallbackAction<ActivateIntent>(
176
+ onInvoke: (_) {
177
+ widget.onSkip();
178
+ return null;
179
+ },
180
+ ),
181
+ },
182
+ onShowFocusHighlight: _setShown,
183
+ child: CompositedTransformTarget(
184
+ link: _link,
185
+ child: OverlayPortal(
186
+ controller: _overlay,
187
+ overlayChildBuilder: (_) => CompositedTransformFollower(
188
+ link: _link,
189
+ showWhenUnlinked: false,
190
+ child: Align(
191
+ alignment: Alignment.topLeft,
192
+ child: Material(
193
+ color: Colors.transparent,
194
+ child: GestureDetector(
195
+ onTap: widget.onSkip,
196
+ child: Container(
197
+ padding: const EdgeInsets.symmetric(
198
+ horizontal: KasySpacing.md,
199
+ vertical: KasySpacing.sm,
200
+ ),
201
+ decoration: BoxDecoration(
202
+ color: c.surface,
203
+ borderRadius: BorderRadius.circular(KasyRadius.md),
204
+ border: Border.all(color: c.primary, width: 1.5),
205
+ boxShadow: <BoxShadow>[
206
+ BoxShadow(
207
+ color: c.onSurface.withValues(alpha: 0.18),
208
+ blurRadius: 16,
209
+ offset: const Offset(0, 4),
210
+ ),
211
+ ],
212
+ ),
213
+ child: Text(label, style: labelStyle),
214
+ ),
215
+ ),
216
+ ),
217
+ ),
218
+ ),
219
+ child: const SizedBox.shrink(),
220
+ ),
221
+ ),
222
+ );
223
+ }
224
+ }
@@ -1,7 +1,10 @@
1
1
  import 'package:flutter/material.dart';
2
- import 'package:kasy_kit/components/kasy_web_header.dart';
2
+ import 'package:kasy_kit/components/kasy_app_bar.dart';
3
+ import 'package:kasy_kit/core/chrome/app_bar_config.dart';
4
+ import 'package:kasy_kit/core/chrome/app_bar_scope.dart';
3
5
  import 'package:kasy_kit/core/theme/theme.dart';
4
6
  import 'package:kasy_kit/core/widgets/responsive_layout.dart';
7
+ import 'package:kasy_kit/features/notifications/ui/widgets/web_notifications_bell.dart';
5
8
 
6
9
  /// Focus target of the currently mounted content, exposed to the "skip to
7
10
  /// content" link (which lives in the shell, outside the routed page).
@@ -12,9 +15,10 @@ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
12
15
  /// always jumps to the most recently mounted page.
13
16
  FocusNode? kasyContentFocusTarget;
14
17
 
15
- /// On desktop (≥ [DeviceType.large]) this puts the [KasyWebHeader] at the top of
16
- /// the content area, above the routed page. On phone/tablet it is transparent
17
- /// (returns the page untouched) — there the page keeps its own [KasyAppBar].
18
+ /// On desktop (≥ [DeviceType.large]) this puts the [KasyAppBar.application] at the
19
+ /// top of the content area, above the routed page. On phone/tablet it is
20
+ /// transparent (returns the page untouched) — there the page keeps its own
21
+ /// [KasyAppBar].
18
22
  ///
19
23
  /// Wrap each routed page with this in the bottom router so the header is present
20
24
  /// across navigation without touching individual pages.
@@ -62,9 +66,14 @@ class _WebContentWrapperState extends State<WebContentWrapper> {
62
66
  // Keyboard Tab order across the whole app: sidebar (1, set in BottomMenu) →
63
67
  // header (2) → content (3). Each block is its own FocusTraversalGroup so its
64
68
  // internal order stays natural (e.g. header: search → theme → … → create).
65
- return ColoredBox(
66
- color: context.colors.background,
67
- child: Column(
69
+ //
70
+ // KasyAppBarScope marks this subtree as "has an application bar above", so the
71
+ // page-level KasyAppBar hides on desktop here (the application bar owns the
72
+ // chrome). Outside this scope (full-screen pushed routes) the bar stays visible.
73
+ return KasyAppBarScope(
74
+ child: ColoredBox(
75
+ color: context.colors.background,
76
+ child: Column(
68
77
  crossAxisAlignment: CrossAxisAlignment.stretch,
69
78
  children: [
70
79
  // The sidebar carries the user profile here, so the header drops its
@@ -72,9 +81,9 @@ class _WebContentWrapperState extends State<WebContentWrapper> {
72
81
  FocusTraversalOrder(
73
82
  order: const NumericFocusOrder(2),
74
83
  child: FocusTraversalGroup(
75
- child: KasyWebHeader(
84
+ child: KasyAppBar.application(
76
85
  onToggleTheme: () => ThemeProvider.of(context).toggle(),
77
- onNotifications: () {},
86
+ notifications: const WebNotificationsBell(),
78
87
  onCreate: () {},
79
88
  showAvatar: false,
80
89
  ),
@@ -91,6 +100,7 @@ class _WebContentWrapperState extends State<WebContentWrapper> {
91
100
  ),
92
101
  ),
93
102
  ],
103
+ ),
94
104
  ),
95
105
  );
96
106
  }