kasy-cli 1.25.0 → 1.26.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 (31) hide show
  1. package/lib/commands/new.js +53 -3
  2. package/lib/utils/i18n/messages-en.js +3 -0
  3. package/lib/utils/i18n/messages-es.js +3 -0
  4. package/lib/utils/i18n/messages-pt.js +3 -0
  5. package/package.json +1 -1
  6. package/templates/firebase/lib/components/kasy_app_bar.dart +12 -6
  7. package/templates/firebase/lib/components/kasy_avatar.dart +17 -10
  8. package/templates/firebase/lib/components/kasy_checkbox.dart +24 -15
  9. package/templates/firebase/lib/components/kasy_date_picker.dart +27 -20
  10. package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +19 -14
  11. package/templates/firebase/lib/components/kasy_tabs.dart +75 -65
  12. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +103 -3
  13. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +45 -2
  14. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +9 -4
  15. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -0
  16. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +2 -0
  17. package/templates/firebase/lib/features/home/home_feed.dart +21 -5
  18. package/templates/firebase/lib/features/home/home_image_grid.dart +83 -58
  19. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +124 -101
  20. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +1 -0
  21. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +85 -85
  22. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +67 -62
  23. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +37 -23
  24. package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +14 -6
  25. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +9 -3
  26. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +24 -16
  27. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +29 -22
  28. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +19 -12
  29. package/templates/firebase/lib/i18n/en.i18n.json +2 -1
  30. package/templates/firebase/lib/i18n/es.i18n.json +2 -1
  31. package/templates/firebase/lib/i18n/pt.i18n.json +2 -1
@@ -195,6 +195,56 @@ async function confirmIdentities(backend, gcloudAccount, tr, cancel) {
195
195
  }
196
196
  }
197
197
 
198
+ /**
199
+ * Try to get gcloud ready (installed + logged in) without making the user leave
200
+ * the flow. Mirrors the machine-prep we do for Node/Flutter/Firebase: offer to
201
+ * auto-install gcloud (winget on Windows, brew on macOS), make it visible to
202
+ * this process, then run the interactive Google login (their own account — that
203
+ * part can't be automated). On any failure it returns the latest check so the
204
+ * caller keeps its existing fallback (cancel in Quick mode, or ask for a project
205
+ * id elsewhere). Never throws.
206
+ */
207
+ async function ensureGcloudReady(tr) {
208
+ const { spawnSync } = require('node:child_process');
209
+ const { augmentedEnv } = require('../utils/env-tools');
210
+
211
+ let check = await checkGcloudAuth();
212
+ if (check.ok) return check;
213
+
214
+ // 1) Not installed → offer to install it for them.
215
+ if (check.missing === 'gcloud') {
216
+ const guide = getGcloudInstallInstructions();
217
+ if (guide.install) {
218
+ const doInstall = await ui.confirm({
219
+ message: tr('new.firebase.create.gcloudInstallConfirm'),
220
+ initialValue: true,
221
+ });
222
+ if (doInstall) {
223
+ const spinner = ui.timedSpinner();
224
+ spinner.start(tr('new.firebase.create.gcloudInstalling'));
225
+ spawnSync(guide.install, { stdio: 'pipe', shell: true });
226
+ spinner.stop(tr('new.firebase.create.gcloudInstalling'));
227
+ // checkGcloudAuth / the later billing+project calls run with the plain
228
+ // process PATH, which doesn't see a tool winget just dropped on the
229
+ // machine PATH. Inject the known gcloud dir so the rest of the flow works.
230
+ const env = augmentedEnv();
231
+ if (env.PATH) process.env.PATH = env.PATH;
232
+ if (process.platform === 'win32' && env.Path) process.env.Path = env.Path;
233
+ check = await checkGcloudAuth();
234
+ }
235
+ }
236
+ }
237
+
238
+ // 2) Installed but not logged in → run the interactive login (their account).
239
+ if (!check.ok && check.missing === 'auth') {
240
+ ui.log.info(tr('new.firebase.create.gcloudAuthOpening'));
241
+ spawnSync('gcloud auth login', { stdio: 'inherit', shell: true });
242
+ check = await checkGcloudAuth();
243
+ }
244
+
245
+ return check;
246
+ }
247
+
198
248
  function printPrerequisites(tr, backend, firebaseSetupMode = 'existing', checkResults = []) {
199
249
  const gcloudOk = checkResults.every(
200
250
  (r) => !r.name?.includes('gcloud') || r.ok
@@ -644,7 +694,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
644
694
  // ── gcloud is required for Quick mode (creates GCP project + enables Auth via API) ─
645
695
  // Failing here, before the project is generated, is much friendlier than dying mid-flow.
646
696
  if (isQuick) {
647
- const gcloudCheck = await checkGcloudAuth();
697
+ const gcloudCheck = await ensureGcloudReady(tr);
648
698
  if (!gcloudCheck.ok) {
649
699
  ui.log.error(tr('new.firebase.create.gcloudRequired'));
650
700
  if (gcloudCheck.missing === 'gcloud') {
@@ -815,7 +865,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
815
865
  onCancel: cancel,
816
866
  });
817
867
  }
818
- const gcloudCheck = await checkGcloudAuth();
868
+ const gcloudCheck = await ensureGcloudReady(tr);
819
869
  if (!gcloudCheck.ok) {
820
870
  ui.log.error(tr('new.firebase.create.gcloudRequired'));
821
871
  if (gcloudCheck.missing === 'gcloud') {
@@ -1019,7 +1069,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1019
1069
 
1020
1070
  // ── Firebase: create from scratch for Supabase/API (push notifications only) ─
1021
1071
  if ((backend === 'supabase' || backend === 'api') && firebaseSetupMode === 'create') {
1022
- const gcloudCheck = await checkGcloudAuth();
1072
+ const gcloudCheck = await ensureGcloudReady(tr);
1023
1073
  if (!gcloudCheck.ok) {
1024
1074
  ui.log.error(tr('new.firebase.create.gcloudRequired'));
1025
1075
  if (gcloudCheck.missing === 'gcloud') {
@@ -238,6 +238,9 @@ module.exports = {
238
238
  'new.firebase.create.installAfter': 'Then log in',
239
239
  'new.firebase.create.installUrl': 'Or download from',
240
240
  'new.firebase.create.authCommand': 'Run: gcloud auth login',
241
+ 'new.firebase.create.gcloudInstallConfirm': 'Install the Google Cloud CLI (gcloud) automatically now?',
242
+ 'new.firebase.create.gcloudInstalling': 'Installing the Google Cloud CLI…',
243
+ 'new.firebase.create.gcloudAuthOpening': 'Opening the Google sign-in in your browser — log in with your account…',
241
244
  'new.firebase.create.fallbackHint': 'For now, enter an existing Firebase Project ID to continue (or install gcloud and run again):',
242
245
  'new.firebase.q.billingAccount': 'Which billing account to link to the project?',
243
246
  'new.firebase.q.billingAccount.hint': 'Choose the account with available quota (up to 3 projects per account)',
@@ -238,6 +238,9 @@ module.exports = {
238
238
  'new.firebase.create.installAfter': 'Luego inicia sesión',
239
239
  'new.firebase.create.installUrl': 'O descarga en',
240
240
  'new.firebase.create.authCommand': 'Ejecuta: gcloud auth login',
241
+ 'new.firebase.create.gcloudInstallConfirm': '¿Instalar el Google Cloud CLI (gcloud) automáticamente ahora?',
242
+ 'new.firebase.create.gcloudInstalling': 'Instalando el Google Cloud CLI…',
243
+ 'new.firebase.create.gcloudAuthOpening': 'Abriendo el inicio de sesión de Google en el navegador — entra con tu cuenta…',
241
244
  'new.firebase.create.fallbackHint': 'Por ahora, ingresa un Firebase Project ID existente para continuar (o instala gcloud y ejecuta de nuevo):',
242
245
  'new.firebase.q.billingAccount': '¿Qué cuenta de facturación vincular al proyecto?',
243
246
  'new.firebase.q.billingAccount.hint': 'Elige la cuenta con cuota disponible (hasta 3 proyectos por cuenta)',
@@ -238,6 +238,9 @@ module.exports = {
238
238
  'new.firebase.create.installAfter': 'Depois faca login',
239
239
  'new.firebase.create.installUrl': 'Ou baixe em',
240
240
  'new.firebase.create.authCommand': 'Execute: gcloud auth login',
241
+ 'new.firebase.create.gcloudInstallConfirm': 'Instalar o Google Cloud CLI (gcloud) automaticamente agora?',
242
+ 'new.firebase.create.gcloudInstalling': 'Instalando o Google Cloud CLI…',
243
+ 'new.firebase.create.gcloudAuthOpening': 'Abrindo o login do Google no navegador — entre com a sua conta…',
241
244
  'new.firebase.create.fallbackHint': 'Por enquanto, informe um Firebase Project ID existente para continuar (ou instale o gcloud e execute novamente):',
242
245
  'new.firebase.q.billingAccount': 'Qual conta de faturamento vincular ao projeto?',
243
246
  'new.firebase.q.billingAccount.hint': 'Escolha a conta com cota disponível (até 3 projetos por conta)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -29,6 +29,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
29
29
  import 'package:flutter/material.dart';
30
30
  import 'package:flutter/services.dart' show SystemUiOverlayStyle;
31
31
  import 'package:kasy_kit/core/theme/theme.dart';
32
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
32
33
  import 'package:kasy_kit/core/widgets/responsive_layout.dart';
33
34
 
34
35
  /// Inner toolbar band height (orbit hit targets, title baseline).
@@ -468,12 +469,17 @@ class KasyAppBarChromeTap extends StatelessWidget {
468
469
  button: true,
469
470
  enabled: true,
470
471
  label: label,
471
- child: GestureDetector(
472
- behavior: HitTestBehavior.opaque,
473
- onTap: onPressed,
474
- child: ConstrainedBox(
475
- constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
476
- child: Center(child: child),
472
+ child: KasyFocusRing(
473
+ onActivate: onPressed,
474
+ // Circular icon button, so a large radius keeps the focus ring round.
475
+ borderRadius: BorderRadius.circular(999),
476
+ child: GestureDetector(
477
+ behavior: HitTestBehavior.opaque,
478
+ onTap: onPressed,
479
+ child: ConstrainedBox(
480
+ constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
481
+ child: Center(child: child),
482
+ ),
477
483
  ),
478
484
  ),
479
485
  );
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
4
4
  import 'package:flutter/material.dart';
5
5
  import 'package:kasy_kit/components/kasy_avatar_presets.dart';
6
6
  import 'package:kasy_kit/core/theme/theme.dart';
7
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
7
8
 
8
9
  /// Logical sizes for [KasyAvatar] (diameter in logical pixels).
9
10
  enum KasyAvatarSize {
@@ -609,16 +610,22 @@ class _KasyAvatarPressableState extends State<_KasyAvatarPressable>
609
610
  final Widget inner = Semantics(
610
611
  button: true,
611
612
  label: widget.semanticLabel,
612
- child: GestureDetector(
613
- behavior: HitTestBehavior.opaque,
614
- onTap: _tap,
615
- child: ConstrainedBox(
616
- constraints: BoxConstraints(
617
- minWidth: widget.minSide,
618
- minHeight: widget.minSide,
619
- ),
620
- child: Center(
621
- child: Transform.scale(scale: _s, child: widget.child),
613
+ child: KasyFocusRing(
614
+ onActivate: _tap,
615
+ // The avatar is circular, so a large radius keeps the focus ring
616
+ // hugging the circle (radius >= half the diameter).
617
+ borderRadius: BorderRadius.circular(widget.minSide),
618
+ child: GestureDetector(
619
+ behavior: HitTestBehavior.opaque,
620
+ onTap: _tap,
621
+ child: ConstrainedBox(
622
+ constraints: BoxConstraints(
623
+ minWidth: widget.minSide,
624
+ minHeight: widget.minSide,
625
+ ),
626
+ child: Center(
627
+ child: Transform.scale(scale: _s, child: widget.child),
628
+ ),
622
629
  ),
623
630
  ),
624
631
  ),
@@ -1,5 +1,6 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:kasy_kit/core/theme/theme.dart';
3
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
3
4
 
4
5
  /// Shape of the checkbox box.
5
6
  enum KasyCheckboxShape {
@@ -169,21 +170,29 @@ class _KasyCheckboxState extends State<KasyCheckbox>
169
170
  final bool hasLabel =
170
171
  widget.label != null || widget.description != null;
171
172
 
172
- return GestureDetector(
173
- onTap: widget._isDisabled ? null : () => widget.onChanged!(!widget.value),
174
- behavior: HitTestBehavior.opaque,
175
- child: hasLabel
176
- ? Row(
177
- children: [
178
- box,
179
- const SizedBox(width: KasySpacing.smd),
180
- Expanded(child: _LabelColumn(widget: widget)),
181
- ],
182
- )
183
- : Padding(
184
- padding: const EdgeInsets.all(11),
185
- child: box,
186
- ),
173
+ final VoidCallback? onTap =
174
+ widget._isDisabled ? null : () => widget.onChanged!(!widget.value);
175
+
176
+ return KasyFocusRing(
177
+ enabled: onTap != null,
178
+ onActivate: onTap,
179
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
180
+ child: GestureDetector(
181
+ onTap: onTap,
182
+ behavior: HitTestBehavior.opaque,
183
+ child: hasLabel
184
+ ? Row(
185
+ children: [
186
+ box,
187
+ const SizedBox(width: KasySpacing.smd),
188
+ Expanded(child: _LabelColumn(widget: widget)),
189
+ ],
190
+ )
191
+ : Padding(
192
+ padding: const EdgeInsets.all(11),
193
+ child: box,
194
+ ),
195
+ ),
187
196
  );
188
197
  }
189
198
  }
@@ -3,6 +3,7 @@ import 'package:kasy_kit/components/kasy_bottom_sheet.dart';
3
3
  import 'package:kasy_kit/components/kasy_dialog.dart';
4
4
  import 'package:kasy_kit/components/kasy_text_field.dart';
5
5
  import 'package:kasy_kit/core/theme/theme.dart';
6
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
6
7
  import 'package:kasy_kit/core/widgets/kasy_hover.dart';
7
8
 
8
9
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1038,26 +1039,32 @@ class _KasyDatePickerState extends State<KasyDatePicker>
1038
1039
  cursor: widget.enabled
1039
1040
  ? SystemMouseCursors.click
1040
1041
  : SystemMouseCursors.basic,
1041
- child: GestureDetector(
1042
- behavior: HitTestBehavior.opaque,
1043
- onTap: _toggleCalendar,
1044
- child: IgnorePointer(
1045
- child: KasyTextField(
1046
- key: _fieldKey,
1047
- controller: _displayController,
1048
- focusNode: _fieldFocusNode,
1049
- readOnly: true,
1050
- enabled: widget.enabled,
1051
- hint: _resolvedPlaceholder,
1052
- isInvalid: hasInvalidState,
1053
- variant: widget.variant,
1054
- focusBorder: widget.focusBorder,
1055
- // No caret, no selection handles, no "blue text" when the
1056
- // trigger is focused while the calendar is open — keeps the
1057
- // field reading as a button, not an editable input.
1058
- enableInteractiveSelection: false,
1059
- suffix:
1060
- widget.showSuffix ? const Icon(KasyIcons.calendar) : null,
1042
+ child: KasyFocusRing(
1043
+ onActivate: _toggleCalendar,
1044
+ // Match the text field corner radius so the ring hugs the trigger.
1045
+ borderRadius: BorderRadius.circular(KasyRadius.md),
1046
+ child: GestureDetector(
1047
+ behavior: HitTestBehavior.opaque,
1048
+ onTap: _toggleCalendar,
1049
+ child: IgnorePointer(
1050
+ child: KasyTextField(
1051
+ key: _fieldKey,
1052
+ controller: _displayController,
1053
+ focusNode: _fieldFocusNode,
1054
+ readOnly: true,
1055
+ enabled: widget.enabled,
1056
+ hint: _resolvedPlaceholder,
1057
+ isInvalid: hasInvalidState,
1058
+ variant: widget.variant,
1059
+ focusBorder: widget.focusBorder,
1060
+ // No caret, no selection handles, no "blue text" when the
1061
+ // trigger is focused while the calendar is open — keeps the
1062
+ // field reading as a button, not an editable input.
1063
+ enableInteractiveSelection: false,
1064
+ suffix: widget.showSuffix
1065
+ ? const Icon(KasyIcons.calendar)
1066
+ : null,
1067
+ ),
1061
1068
  ),
1062
1069
  ),
1063
1070
  ),
@@ -4,6 +4,7 @@ import 'package:kasy_kit/components/kasy_button.dart';
4
4
  import 'package:kasy_kit/components/kasy_text_field_otp.dart';
5
5
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
6
6
  import 'package:kasy_kit/core/theme/theme.dart';
7
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
7
8
 
8
9
  /// Built-in layouts for [showKasyOtpVerificationBottomSheet].
9
10
  enum KasyOtpVerificationSheetPreset {
@@ -209,21 +210,25 @@ class _SheetResendCodeLinkState extends State<_SheetResendCodeLink> {
209
210
 
210
211
  @override
211
212
  Widget build(BuildContext context) {
212
- return GestureDetector(
213
- onTap: _handleTap,
214
- onTapCancel: () => setState(() => _pressed = false),
215
- child: AnimatedScale(
216
- scale: _pressed ? 0.93 : 1.0,
217
- duration: const Duration(milliseconds: 120),
218
- curve: Curves.easeOutCubic,
219
- child: AnimatedOpacity(
220
- opacity: _pressed ? 0.55 : 1.0,
213
+ return KasyFocusRing(
214
+ onActivate: _handleTap,
215
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
216
+ child: GestureDetector(
217
+ onTap: _handleTap,
218
+ onTapCancel: () => setState(() => _pressed = false),
219
+ child: AnimatedScale(
220
+ scale: _pressed ? 0.93 : 1.0,
221
221
  duration: const Duration(milliseconds: 120),
222
- child: Text(
223
- 'Resend code',
224
- style: context.textTheme.bodyMedium?.copyWith(
225
- color: context.colors.onBackground,
226
- fontWeight: FontWeight.w700,
222
+ curve: Curves.easeOutCubic,
223
+ child: AnimatedOpacity(
224
+ opacity: _pressed ? 0.55 : 1.0,
225
+ duration: const Duration(milliseconds: 120),
226
+ child: Text(
227
+ 'Resend code',
228
+ style: context.textTheme.bodyMedium?.copyWith(
229
+ color: context.colors.onBackground,
230
+ fontWeight: FontWeight.w700,
231
+ ),
227
232
  ),
228
233
  ),
229
234
  ),
@@ -507,35 +507,40 @@ class _PrimaryTabState extends State<_PrimaryTab> {
507
507
  ),
508
508
  );
509
509
 
510
- final Widget gesture = GestureDetector(
511
- onTap: widget.onTap,
512
- behavior: HitTestBehavior.opaque,
513
- child: Padding(
514
- padding: verticalLayout
515
- // Fill+icon: 12px all sides — per Figma spec.
516
- ? const EdgeInsets.all(12)
517
- // Default: 6px vertical, 12px horizontal — per Figma spec.
518
- : const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
519
- child: verticalLayout
520
- ? Column(
521
- mainAxisSize: MainAxisSize.min,
522
- mainAxisAlignment: MainAxisAlignment.center,
523
- children: [
524
- iconWidget,
525
- const SizedBox(height: 6),
526
- labelWidget,
527
- ],
528
- )
529
- : Row(
530
- mainAxisSize: MainAxisSize.min,
531
- mainAxisAlignment: MainAxisAlignment.center,
532
- children: [
533
- if (item.icon != null) iconWidget,
534
- if (item.icon != null && hasLabel)
535
- const SizedBox(width: 6),
536
- if (hasLabel) labelWidget,
537
- ],
538
- ),
510
+ final Widget gesture = KasyFocusRing(
511
+ enabled: widget.onTap != null,
512
+ onActivate: widget.onTap,
513
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
514
+ child: GestureDetector(
515
+ onTap: widget.onTap,
516
+ behavior: HitTestBehavior.opaque,
517
+ child: Padding(
518
+ padding: verticalLayout
519
+ // Fill+icon: 12px all sides — per Figma spec.
520
+ ? const EdgeInsets.all(12)
521
+ // Default: 6px vertical, 12px horizontal — per Figma spec.
522
+ : const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
523
+ child: verticalLayout
524
+ ? Column(
525
+ mainAxisSize: MainAxisSize.min,
526
+ mainAxisAlignment: MainAxisAlignment.center,
527
+ children: [
528
+ iconWidget,
529
+ const SizedBox(height: 6),
530
+ labelWidget,
531
+ ],
532
+ )
533
+ : Row(
534
+ mainAxisSize: MainAxisSize.min,
535
+ mainAxisAlignment: MainAxisAlignment.center,
536
+ children: [
537
+ if (item.icon != null) iconWidget,
538
+ if (item.icon != null && hasLabel)
539
+ const SizedBox(width: 6),
540
+ if (hasLabel) labelWidget,
541
+ ],
542
+ ),
543
+ ),
539
544
  ),
540
545
  );
541
546
 
@@ -598,45 +603,50 @@ class _SecondaryTabState extends State<_SecondaryTab> {
598
603
  // selected color so it reads as interactive (mobile keeps the flat look).
599
604
  final Color fg = (selected || _hovered) ? c.onSurface : c.muted;
600
605
 
601
- final Widget gesture = GestureDetector(
602
- onTap: widget.onTap,
603
- behavior: HitTestBehavior.opaque,
604
- child: Padding(
605
- // top:4 bottom:6 horizontal:12 — per Figma spec.
606
- // Extra bottom padding visually balances the 2px underline indicator.
607
- padding: const EdgeInsets.only(
608
- top: 4,
609
- bottom: 6,
610
- left: 12,
611
- right: 12,
612
- ),
613
- child: Row(
614
- mainAxisSize: MainAxisSize.min,
615
- mainAxisAlignment: MainAxisAlignment.center,
616
- children: [
617
- if (item.icon != null) ...[
618
- Opacity(
619
- opacity: disabled ? 0.4 : 1.0,
620
- child: Icon(
621
- item.icon,
622
- size: 16,
623
- color: fg,
624
- ),
625
- ),
626
- if (hasLabel) const SizedBox(width: 6),
627
- ],
628
- if (hasLabel)
629
- Opacity(
630
- opacity: disabled ? 0.4 : 1.0,
631
- child: Text(
632
- item.label,
633
- // Use labelLarge as defined in the Kasy theme (14px/w600).
634
- style: context.textTheme.labelLarge?.copyWith(
606
+ final Widget gesture = KasyFocusRing(
607
+ enabled: widget.onTap != null,
608
+ onActivate: widget.onTap,
609
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
610
+ child: GestureDetector(
611
+ onTap: widget.onTap,
612
+ behavior: HitTestBehavior.opaque,
613
+ child: Padding(
614
+ // top:4 bottom:6 horizontal:12 — per Figma spec.
615
+ // Extra bottom padding visually balances the 2px underline indicator.
616
+ padding: const EdgeInsets.only(
617
+ top: 4,
618
+ bottom: 6,
619
+ left: 12,
620
+ right: 12,
621
+ ),
622
+ child: Row(
623
+ mainAxisSize: MainAxisSize.min,
624
+ mainAxisAlignment: MainAxisAlignment.center,
625
+ children: [
626
+ if (item.icon != null) ...[
627
+ Opacity(
628
+ opacity: disabled ? 0.4 : 1.0,
629
+ child: Icon(
630
+ item.icon,
631
+ size: 16,
635
632
  color: fg,
636
633
  ),
637
634
  ),
638
- ),
639
- ],
635
+ if (hasLabel) const SizedBox(width: 6),
636
+ ],
637
+ if (hasLabel)
638
+ Opacity(
639
+ opacity: disabled ? 0.4 : 1.0,
640
+ child: Text(
641
+ item.label,
642
+ // Use labelLarge as defined in the Kasy theme (14px/w600).
643
+ style: context.textTheme.labelLarge?.copyWith(
644
+ color: fg,
645
+ ),
646
+ ),
647
+ ),
648
+ ],
649
+ ),
640
650
  ),
641
651
  ),
642
652
  );
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
6
6
  import 'package:kasy_kit/components/kasy_sidebar.dart';
7
7
  import 'package:kasy_kit/core/bottom_menu/bottom_router.dart';
8
8
  import 'package:kasy_kit/core/bottom_menu/kasy_bottom_bar_factory.dart';
9
+ import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
9
10
  import 'package:kasy_kit/core/data/models/user.dart';
10
11
  import 'package:kasy_kit/core/states/logout_action.dart';
11
12
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
@@ -220,17 +221,116 @@ class _FocusableSidebarState extends State<_FocusableSidebar> {
220
221
  super.dispose();
221
222
  }
222
223
 
224
+ // "Skip to content" moves the primary focus into the routed content, so the
225
+ // next Tab steps to the first real control there instead of the sidebar.
226
+ void _skipToContent() => kasyContentFocusTarget?.requestFocus();
227
+
223
228
  @override
224
229
  Widget build(BuildContext context) {
230
+ // Ordered so the first Tab reveals the "skip to content" link (order 0),
231
+ // then the sidebar (order 1). The header (2) and content (3) come after,
232
+ // ordered by the scaffold's OrderedTraversalPolicy. The invisible anchor
233
+ // holds the initial focus so that very first Tab lands on the skip link.
225
234
  return FocusTraversalGroup(
226
- // The anchor is a zero-size sibling so it never affects the sidebar's
227
- // layout; it exists only to hold the initial keyboard focus (see above).
235
+ policy: OrderedTraversalPolicy(),
228
236
  child: Stack(
237
+ clipBehavior: Clip.none,
229
238
  children: [
230
- widget.child,
239
+ FocusTraversalOrder(
240
+ order: const NumericFocusOrder(1),
241
+ child: FocusTraversalGroup(child: widget.child),
242
+ ),
243
+ // Zero-size sibling; only holds the initial keyboard focus.
231
244
  Focus(focusNode: _anchor, child: const SizedBox.shrink()),
245
+ Positioned(
246
+ top: KasySpacing.sm,
247
+ left: KasySpacing.sm,
248
+ child: FocusTraversalOrder(
249
+ order: const NumericFocusOrder(0),
250
+ child: _SkipToContentLink(onSkip: _skipToContent),
251
+ ),
252
+ ),
232
253
  ],
233
254
  ),
234
255
  );
235
256
  }
236
257
  }
258
+
259
+ /// The "skip to content" link (WCAG 2.4.1 "Bypass Blocks"). It is the first Tab
260
+ /// stop on every screen: pressing Tab once reveals it above the sidebar, Enter
261
+ /// jumps focus into the content, and pressing Tab again moves on to the sidebar.
262
+ /// It only paints while focused via the keyboard, so pointer/touch users never
263
+ /// see it. Mirrors the pattern used by Stripe, GitHub, etc.
264
+ class _SkipToContentLink extends StatefulWidget {
265
+ final VoidCallback onSkip;
266
+
267
+ const _SkipToContentLink({required this.onSkip});
268
+
269
+ @override
270
+ State<_SkipToContentLink> createState() => _SkipToContentLinkState();
271
+ }
272
+
273
+ class _SkipToContentLinkState extends State<_SkipToContentLink> {
274
+ bool _show = false;
275
+
276
+ static const Map<ShortcutActivator, Intent> _shortcuts =
277
+ <ShortcutActivator, Intent>{
278
+ SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
279
+ SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(),
280
+ SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
281
+ };
282
+
283
+ @override
284
+ Widget build(BuildContext context) {
285
+ final KasyColors c = context.colors;
286
+ return FocusableActionDetector(
287
+ shortcuts: _shortcuts,
288
+ actions: <Type, Action<Intent>>{
289
+ ActivateIntent: CallbackAction<ActivateIntent>(
290
+ onInvoke: (_) {
291
+ widget.onSkip();
292
+ return null;
293
+ },
294
+ ),
295
+ },
296
+ onShowFocusHighlight: (bool show) {
297
+ if (mounted && show != _show) setState(() => _show = show);
298
+ },
299
+ child: IgnorePointer(
300
+ ignoring: !_show,
301
+ child: AnimatedOpacity(
302
+ opacity: _show ? 1 : 0,
303
+ duration: const Duration(milliseconds: 120),
304
+ child: GestureDetector(
305
+ onTap: widget.onSkip,
306
+ child: Container(
307
+ padding: const EdgeInsets.symmetric(
308
+ horizontal: KasySpacing.md,
309
+ vertical: KasySpacing.sm,
310
+ ),
311
+ decoration: BoxDecoration(
312
+ color: c.surface,
313
+ borderRadius: BorderRadius.circular(KasyRadius.md),
314
+ border: Border.all(color: c.primary, width: 1.5),
315
+ boxShadow: <BoxShadow>[
316
+ BoxShadow(
317
+ color: c.onSurface.withValues(alpha: 0.18),
318
+ blurRadius: 16,
319
+ offset: const Offset(0, 4),
320
+ ),
321
+ ],
322
+ ),
323
+ child: Text(
324
+ context.t.navigation.skip_to_content,
325
+ style: context.textTheme.bodyMedium?.copyWith(
326
+ color: c.onSurface,
327
+ fontWeight: FontWeight.w600,
328
+ ),
329
+ ),
330
+ ),
331
+ ),
332
+ ),
333
+ ),
334
+ );
335
+ }
336
+ }