kasy-cli 1.25.0 → 1.27.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 +63 -3
  2. package/lib/utils/i18n/messages-en.js +4 -0
  3. package/lib/utils/i18n/messages-es.js +4 -0
  4. package/lib/utils/i18n/messages-pt.js +4 -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 +98 -2
  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,66 @@ 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, exec } = 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 → tell the user it's missing, then offer to install it.
215
+ if (check.missing === 'gcloud') {
216
+ const guide = getGcloudInstallInstructions();
217
+ if (guide.install) {
218
+ ui.log.warn(tr('new.firebase.create.gcloudMissing'));
219
+ const doInstall = await ui.confirm({
220
+ message: tr('new.firebase.create.gcloudInstallConfirm'),
221
+ initialValue: true,
222
+ });
223
+ if (doInstall) {
224
+ const spinner = ui.timedSpinner();
225
+ spinner.start(tr('new.firebase.create.gcloudInstalling'));
226
+ // Run async (NOT spawnSync): a synchronous child blocks the event loop,
227
+ // which freezes the spinner — the user stares at a dead screen for
228
+ // minutes. exec keeps the clock ticking so it's clear it's working.
229
+ await new Promise((resolve) => {
230
+ exec(
231
+ guide.install,
232
+ { env: augmentedEnv(), timeout: 600_000, windowsHide: true, maxBuffer: 50 * 1024 * 1024 },
233
+ () => resolve()
234
+ );
235
+ });
236
+ spinner.stop(tr('new.firebase.create.gcloudInstalling'));
237
+ // checkGcloudAuth and the later billing/project calls use the plain
238
+ // process PATH, which doesn't see a tool winget just put on the machine
239
+ // PATH. Inject the known gcloud dir so the rest of the flow works.
240
+ const env = augmentedEnv();
241
+ if (env.PATH) process.env.PATH = env.PATH;
242
+ if (process.platform === 'win32' && env.Path) process.env.Path = env.Path;
243
+ check = await checkGcloudAuth();
244
+ }
245
+ }
246
+ }
247
+
248
+ // 2) Installed but not logged in → run the interactive login (their account).
249
+ if (!check.ok && check.missing === 'auth') {
250
+ ui.log.info(tr('new.firebase.create.gcloudAuthOpening'));
251
+ spawnSync('gcloud auth login', { stdio: 'inherit', shell: true });
252
+ check = await checkGcloudAuth();
253
+ }
254
+
255
+ return check;
256
+ }
257
+
198
258
  function printPrerequisites(tr, backend, firebaseSetupMode = 'existing', checkResults = []) {
199
259
  const gcloudOk = checkResults.every(
200
260
  (r) => !r.name?.includes('gcloud') || r.ok
@@ -644,7 +704,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
644
704
  // ── gcloud is required for Quick mode (creates GCP project + enables Auth via API) ─
645
705
  // Failing here, before the project is generated, is much friendlier than dying mid-flow.
646
706
  if (isQuick) {
647
- const gcloudCheck = await checkGcloudAuth();
707
+ const gcloudCheck = await ensureGcloudReady(tr);
648
708
  if (!gcloudCheck.ok) {
649
709
  ui.log.error(tr('new.firebase.create.gcloudRequired'));
650
710
  if (gcloudCheck.missing === 'gcloud') {
@@ -815,7 +875,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
815
875
  onCancel: cancel,
816
876
  });
817
877
  }
818
- const gcloudCheck = await checkGcloudAuth();
878
+ const gcloudCheck = await ensureGcloudReady(tr);
819
879
  if (!gcloudCheck.ok) {
820
880
  ui.log.error(tr('new.firebase.create.gcloudRequired'));
821
881
  if (gcloudCheck.missing === 'gcloud') {
@@ -1019,7 +1079,7 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1019
1079
 
1020
1080
  // ── Firebase: create from scratch for Supabase/API (push notifications only) ─
1021
1081
  if ((backend === 'supabase' || backend === 'api') && firebaseSetupMode === 'create') {
1022
- const gcloudCheck = await checkGcloudAuth();
1082
+ const gcloudCheck = await ensureGcloudReady(tr);
1023
1083
  if (!gcloudCheck.ok) {
1024
1084
  ui.log.error(tr('new.firebase.create.gcloudRequired'));
1025
1085
  if (gcloudCheck.missing === 'gcloud') {
@@ -238,6 +238,10 @@ 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.gcloudMissing': 'The Google Cloud CLI (gcloud) is required to create the Firebase project from scratch, and it is not installed yet.',
242
+ 'new.firebase.create.gcloudInstallConfirm': 'Install the Google Cloud CLI (gcloud) automatically now?',
243
+ 'new.firebase.create.gcloudInstalling': 'Installing the Google Cloud CLI…',
244
+ 'new.firebase.create.gcloudAuthOpening': 'Opening the Google sign-in in your browser — log in with your account…',
241
245
  'new.firebase.create.fallbackHint': 'For now, enter an existing Firebase Project ID to continue (or install gcloud and run again):',
242
246
  'new.firebase.q.billingAccount': 'Which billing account to link to the project?',
243
247
  'new.firebase.q.billingAccount.hint': 'Choose the account with available quota (up to 3 projects per account)',
@@ -238,6 +238,10 @@ 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.gcloudMissing': 'El Google Cloud CLI (gcloud) es necesario para crear el proyecto Firebase desde cero, y aún no está instalado.',
242
+ 'new.firebase.create.gcloudInstallConfirm': '¿Instalar el Google Cloud CLI (gcloud) automáticamente ahora?',
243
+ 'new.firebase.create.gcloudInstalling': 'Instalando el Google Cloud CLI…',
244
+ 'new.firebase.create.gcloudAuthOpening': 'Abriendo el inicio de sesión de Google en el navegador — entra con tu cuenta…',
241
245
  'new.firebase.create.fallbackHint': 'Por ahora, ingresa un Firebase Project ID existente para continuar (o instala gcloud y ejecuta de nuevo):',
242
246
  'new.firebase.q.billingAccount': '¿Qué cuenta de facturación vincular al proyecto?',
243
247
  'new.firebase.q.billingAccount.hint': 'Elige la cuenta con cuota disponible (hasta 3 proyectos por cuenta)',
@@ -238,6 +238,10 @@ 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.gcloudMissing': 'O Google Cloud CLI (gcloud) é necessário para criar o projeto Firebase do zero, e ainda não está instalado.',
242
+ 'new.firebase.create.gcloudInstallConfirm': 'Instalar o Google Cloud CLI (gcloud) automaticamente agora?',
243
+ 'new.firebase.create.gcloudInstalling': 'Instalando o Google Cloud CLI…',
244
+ 'new.firebase.create.gcloudAuthOpening': 'Abrindo o login do Google no navegador — entre com a sua conta…',
241
245
  'new.firebase.create.fallbackHint': 'Por enquanto, informe um Firebase Project ID existente para continuar (ou instale o gcloud e execute novamente):',
242
246
  'new.firebase.q.billingAccount': 'Qual conta de faturamento vincular ao projeto?',
243
247
  '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.27.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
  );