kasy-cli 1.24.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 (41) hide show
  1. package/lib/commands/new.js +53 -3
  2. package/lib/utils/flutter-install.js +26 -1
  3. package/lib/utils/i18n/messages-en.js +3 -0
  4. package/lib/utils/i18n/messages-es.js +3 -0
  5. package/lib/utils/i18n/messages-pt.js +3 -0
  6. package/package.json +1 -1
  7. package/templates/firebase/lib/components/kasy_app_bar.dart +12 -6
  8. package/templates/firebase/lib/components/kasy_avatar.dart +17 -10
  9. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  10. package/templates/firebase/lib/components/kasy_checkbox.dart +24 -15
  11. package/templates/firebase/lib/components/kasy_date_picker.dart +27 -20
  12. package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +19 -14
  13. package/templates/firebase/lib/components/kasy_sidebar.dart +6 -0
  14. package/templates/firebase/lib/components/kasy_tabs.dart +76 -65
  15. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +103 -3
  16. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +45 -2
  17. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +37 -1
  18. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +9 -4
  19. package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +1 -0
  20. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +10 -5
  21. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +1 -0
  22. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -0
  23. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +2 -0
  24. package/templates/firebase/lib/features/home/home_feed.dart +21 -5
  25. package/templates/firebase/lib/features/home/home_image_grid.dart +83 -58
  26. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +124 -101
  27. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +1 -0
  28. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +85 -85
  29. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +67 -62
  30. package/templates/firebase/lib/features/settings/settings_page.dart +17 -1
  31. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +37 -23
  32. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +63 -48
  33. package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +14 -6
  34. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +2 -0
  35. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +9 -3
  36. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +24 -16
  37. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +29 -22
  38. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +19 -12
  39. package/templates/firebase/lib/i18n/en.i18n.json +2 -1
  40. package/templates/firebase/lib/i18n/es.i18n.json +2 -1
  41. 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') {
@@ -43,12 +43,24 @@ $ProgressPreference = 'SilentlyContinue'
43
43
  $dest = Join-Path $env:LOCALAPPDATA 'flutter'
44
44
  $bin = Join-Path $dest 'bin'
45
45
 
46
+ function Sync-Path {
47
+ # Pull the persistent PATH (Machine + User) into THIS session so a tool just
48
+ # installed by winget (Git) becomes visible right away.
49
+ $m = [Environment]::GetEnvironmentVariable('Path', 'Machine')
50
+ $u = [Environment]::GetEnvironmentVariable('Path', 'User')
51
+ $env:Path = (@($m, $u) | Where-Object { $_ }) -join ';'
52
+ }
53
+
46
54
  # 1. Git — Flutter uses it internally and won't run without it.
47
55
  if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
48
56
  if (Get-Command winget -ErrorAction SilentlyContinue) {
49
57
  winget install --id Git.Git -e --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity | Out-Null
58
+ Sync-Path
50
59
  }
51
60
  }
61
+ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
62
+ throw 'Git is required for Flutter but is not available. Install Git and run this again.'
63
+ }
52
64
 
53
65
  # 2. Flutter SDK — download + unzip, unless it's already there.
54
66
  if (-not (Test-Path (Join-Path $bin 'flutter.bat'))) {
@@ -67,8 +79,21 @@ if (-not (Test-Path (Join-Path $bin 'flutter.bat'))) {
67
79
  [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $env:LOCALAPPDATA)
68
80
  Remove-Item $zip -Force -ErrorAction SilentlyContinue
69
81
  }
82
+ if (-not (Test-Path (Join-Path $bin 'flutter.bat'))) {
83
+ throw 'Flutter download/extract did not produce flutter.bat.'
84
+ }
85
+
86
+ # 3. Make flutter usable in THIS session and warm it up. The very first flutter
87
+ # command downloads the bundled Dart SDK (a few hundred MB), which can take
88
+ # minutes — doing it here, inside the installer, keeps the caller's follow-up
89
+ # "flutter --version" check fast instead of timing out on the cold first run.
90
+ if ($env:Path -notlike "*$bin*") { $env:Path = "$bin;$env:Path" }
91
+ & (Join-Path $bin 'flutter.bat') --version 2>&1 | Out-Null
92
+ if ($LASTEXITCODE -ne 0) {
93
+ throw 'Flutter was installed but its first run failed (Dart SDK bootstrap). Check your internet connection and run this again.'
94
+ }
70
95
 
71
- # 3. Persist flutter\\bin on the User PATH so every future terminal finds it.
96
+ # 4. Persist flutter\\bin on the User PATH so every future terminal finds it.
72
97
  $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
73
98
  if (-not $userPath) { $userPath = '' }
74
99
  if (($userPath -split ';') -notcontains $bin) {
@@ -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.24.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
  ),
@@ -119,6 +119,10 @@ class KasyCard extends StatelessWidget {
119
119
  onPressed: onTap!,
120
120
  semanticLabel: semanticLabel ?? 'Card',
121
121
  clipBorderRadius: resolvedRadius,
122
+ // A tappable card is an actionable control, so make it a keyboard
123
+ // tab-stop with the standard focus ring (matches buttons/links).
124
+ focusable: true,
125
+ focusBorderRadius: resolvedRadius,
122
126
  child: margined,
123
127
  );
124
128
  }
@@ -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
  ),
@@ -579,6 +579,8 @@ class _KasySidebarState extends State<KasySidebar> {
579
579
  borderRadius: BorderRadius.circular(_kToggleSize / 2),
580
580
  hoverColor: c.activeBg,
581
581
  pressColor: c.textActive,
582
+ focusable: true,
583
+ focusGapColor: c.bg,
582
584
  onTap: _toggleCollapse,
583
585
  child: Container(
584
586
  width: _kToggleSize,
@@ -1071,6 +1073,8 @@ class _KasySidebarState extends State<KasySidebar> {
1071
1073
  borderRadius: BorderRadius.circular(_kItemRadius),
1072
1074
  hoverColor: c.activeBg,
1073
1075
  pressColor: c.textActive,
1076
+ focusable: true,
1077
+ focusGapColor: c.bg,
1074
1078
  onTap: () => _activateSubItem(label),
1075
1079
  child: Container(
1076
1080
  height: _kSubItemH,
@@ -1298,6 +1302,8 @@ class _ProTooltipIconState extends State<_ProTooltipIcon> {
1298
1302
  borderRadius: BorderRadius.circular(_kItemRadius),
1299
1303
  hoverColor: widget.activeBg,
1300
1304
  pressColor: widget.activeBg,
1305
+ focusable: true,
1306
+ focusGapColor: widget.colors.bg,
1301
1307
  onTap: widget.onTap,
1302
1308
  child: Container(
1303
1309
  padding: const EdgeInsets.symmetric(
@@ -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
  // ─────────────────────────────────────────────────────────────────────────────
5
6
  // Data model
@@ -506,35 +507,40 @@ class _PrimaryTabState extends State<_PrimaryTab> {
506
507
  ),
507
508
  );
508
509
 
509
- final Widget gesture = GestureDetector(
510
- onTap: widget.onTap,
511
- behavior: HitTestBehavior.opaque,
512
- child: Padding(
513
- padding: verticalLayout
514
- // Fill+icon: 12px all sides — per Figma spec.
515
- ? const EdgeInsets.all(12)
516
- // Default: 6px vertical, 12px horizontal — per Figma spec.
517
- : const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
518
- child: verticalLayout
519
- ? Column(
520
- mainAxisSize: MainAxisSize.min,
521
- mainAxisAlignment: MainAxisAlignment.center,
522
- children: [
523
- iconWidget,
524
- const SizedBox(height: 6),
525
- labelWidget,
526
- ],
527
- )
528
- : Row(
529
- mainAxisSize: MainAxisSize.min,
530
- mainAxisAlignment: MainAxisAlignment.center,
531
- children: [
532
- if (item.icon != null) iconWidget,
533
- if (item.icon != null && hasLabel)
534
- const SizedBox(width: 6),
535
- if (hasLabel) labelWidget,
536
- ],
537
- ),
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
+ ),
538
544
  ),
539
545
  );
540
546
 
@@ -597,45 +603,50 @@ class _SecondaryTabState extends State<_SecondaryTab> {
597
603
  // selected color so it reads as interactive (mobile keeps the flat look).
598
604
  final Color fg = (selected || _hovered) ? c.onSurface : c.muted;
599
605
 
600
- final Widget gesture = GestureDetector(
601
- onTap: widget.onTap,
602
- behavior: HitTestBehavior.opaque,
603
- child: Padding(
604
- // top:4 bottom:6 horizontal:12 — per Figma spec.
605
- // Extra bottom padding visually balances the 2px underline indicator.
606
- padding: const EdgeInsets.only(
607
- top: 4,
608
- bottom: 6,
609
- left: 12,
610
- right: 12,
611
- ),
612
- child: Row(
613
- mainAxisSize: MainAxisSize.min,
614
- mainAxisAlignment: MainAxisAlignment.center,
615
- children: [
616
- if (item.icon != null) ...[
617
- Opacity(
618
- opacity: disabled ? 0.4 : 1.0,
619
- child: Icon(
620
- item.icon,
621
- size: 16,
622
- color: fg,
623
- ),
624
- ),
625
- if (hasLabel) const SizedBox(width: 6),
626
- ],
627
- if (hasLabel)
628
- Opacity(
629
- opacity: disabled ? 0.4 : 1.0,
630
- child: Text(
631
- item.label,
632
- // Use labelLarge as defined in the Kasy theme (14px/w600).
633
- 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,
634
632
  color: fg,
635
633
  ),
636
634
  ),
637
- ),
638
- ],
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
+ ),
639
650
  ),
640
651
  ),
641
652
  );