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.
- 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 +2 -2
- 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 +7 -1
- package/templates/firebase/DESIGN_SYSTEM.md +13 -0
- 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 +394 -25
- 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/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,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/
|
|
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:
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|