kasy-cli 1.37.1 → 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.
Files changed (49) hide show
  1. package/lib/scaffold/CHANGELOG.json +9 -0
  2. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  3. package/lib/scaffold/backends/patch-base-hashes.json +2 -2
  4. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  5. package/package.json +1 -1
  6. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  7. package/templates/firebase/AGENTS.md +7 -1
  8. package/templates/firebase/DESIGN_SYSTEM.md +13 -0
  9. package/templates/firebase/lib/components/kasy_app_bar.dart +57 -16
  10. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  11. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  12. package/templates/firebase/lib/components/kasy_sidebar.dart +394 -25
  13. package/templates/firebase/lib/components/kasy_web_header.dart +11 -3
  14. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +2 -213
  15. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  16. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +12 -4
  17. package/templates/firebase/lib/core/chrome/web_header_scope.dart +20 -0
  18. package/templates/firebase/lib/core/data/api/user_api.dart +4 -0
  19. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  20. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  21. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  22. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  23. package/templates/firebase/lib/core/states/user_state_notifier.dart +41 -0
  24. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  25. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  26. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  27. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  28. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  29. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +498 -466
  30. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  31. package/templates/firebase/lib/features/home/home_components_page.dart +16 -6
  32. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +7 -0
  33. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +23 -13
  34. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -0
  35. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  36. package/templates/firebase/lib/features/settings/settings_page.dart +51 -38
  37. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +549 -376
  38. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  39. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +266 -141
  40. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +23 -30
  41. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +30 -49
  42. package/templates/firebase/lib/i18n/en.i18n.json +23 -9
  43. package/templates/firebase/lib/i18n/es.i18n.json +23 -9
  44. package/templates/firebase/lib/i18n/pt.i18n.json +23 -9
  45. package/templates/firebase/lib/router.dart +43 -25
  46. package/templates/firebase/pubspec.yaml +1 -1
  47. package/templates/firebase/test/admin_shell_chrome_test.dart +104 -0
  48. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  49. 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, _) {
@@ -228,214 +228,3 @@ class BottomMenu extends StatelessWidget {
228
228
  return segments.length < 2;
229
229
  }
230
230
  }
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,9 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:kasy_kit/components/kasy_web_header.dart';
3
+ import 'package:kasy_kit/core/chrome/web_header_scope.dart';
3
4
  import 'package:kasy_kit/core/theme/theme.dart';
4
5
  import 'package:kasy_kit/core/widgets/responsive_layout.dart';
6
+ import 'package:kasy_kit/features/notifications/ui/widgets/web_notifications_bell.dart';
5
7
 
6
8
  /// Focus target of the currently mounted content, exposed to the "skip to
7
9
  /// content" link (which lives in the shell, outside the routed page).
@@ -62,9 +64,14 @@ class _WebContentWrapperState extends State<WebContentWrapper> {
62
64
  // Keyboard Tab order across the whole app: sidebar (1, set in BottomMenu) →
63
65
  // header (2) → content (3). Each block is its own FocusTraversalGroup so its
64
66
  // internal order stays natural (e.g. header: search → theme → … → create).
65
- return ColoredBox(
66
- color: context.colors.background,
67
- child: Column(
67
+ //
68
+ // KasyWebHeaderScope marks this subtree as "has a web header above", so the
69
+ // page-level KasyAppBar hides on desktop here (the header owns the chrome).
70
+ // Outside this scope (full-screen pushed routes) the app bar stays visible.
71
+ return KasyWebHeaderScope(
72
+ child: ColoredBox(
73
+ color: context.colors.background,
74
+ child: Column(
68
75
  crossAxisAlignment: CrossAxisAlignment.stretch,
69
76
  children: [
70
77
  // The sidebar carries the user profile here, so the header drops its
@@ -74,7 +81,7 @@ class _WebContentWrapperState extends State<WebContentWrapper> {
74
81
  child: FocusTraversalGroup(
75
82
  child: KasyWebHeader(
76
83
  onToggleTheme: () => ThemeProvider.of(context).toggle(),
77
- onNotifications: () {},
84
+ notifications: const WebNotificationsBell(),
78
85
  onCreate: () {},
79
86
  showAvatar: false,
80
87
  ),
@@ -91,6 +98,7 @@ class _WebContentWrapperState extends State<WebContentWrapper> {
91
98
  ),
92
99
  ),
93
100
  ],
101
+ ),
94
102
  ),
95
103
  );
96
104
  }
@@ -0,0 +1,20 @@
1
+ import 'package:flutter/widgets.dart';
2
+
3
+ /// Marks the subtree that sits BELOW the desktop web header ([KasyWebHeader]) —
4
+ /// i.e. the shell content area, provided by [WebContentWrapper].
5
+ ///
6
+ /// [KasyAppBar] uses it to decide whether to hide on desktop: INSIDE this scope
7
+ /// the web header already owns the top chrome, so the page app bar hides; OUTSIDE
8
+ /// it (a full-screen pushed route with no web header above) the app bar stays
9
+ /// visible — so the back button is never lost on desktop.
10
+ class KasyWebHeaderScope extends InheritedWidget {
11
+ const KasyWebHeaderScope({super.key, required super.child});
12
+
13
+ /// True when a [KasyWebHeaderScope] is an ancestor (no rebuild dependency —
14
+ /// presence is fixed for a given subtree).
15
+ static bool of(BuildContext context) =>
16
+ context.getInheritedWidgetOfExactType<KasyWebHeaderScope>() != null;
17
+
18
+ @override
19
+ bool updateShouldNotify(KasyWebHeaderScope oldWidget) => false;
20
+ }
@@ -53,6 +53,10 @@ class UserApi {
53
53
  );
54
54
  }
55
55
 
56
+ Stream<String?> watchRole(String id) {
57
+ return _collection.doc(id).snapshots().map((snap) => snap.data()?.role);
58
+ }
59
+
56
60
  Future<void> update(UserEntity user) {
57
61
  final data = user.toJson();
58
62
  data.removeWhere((key, value) => value == null);
@@ -59,6 +59,11 @@ class UserRepository {
59
59
  }
60
60
  }
61
61
 
62
+ /// Streams the user's role so a change made in the backend console (e.g.
63
+ /// promoting someone to "admin") is picked up at runtime without a restart.
64
+ /// Delegates to the backend-specific [UserApi].
65
+ Stream<String?> watchRole(String id) => _userApi.watchRole(id);
66
+
62
67
  /// We updates the user avatar
63
68
  /// We convert the image to jpeg and resize it to 300px width
64
69
  /// and 80% quality to reduce the size of the image