kasy-cli 1.23.0 → 1.25.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/bin/kasy.js CHANGED
@@ -463,9 +463,15 @@ function buildProgram(language) {
463
463
  if (p[p.length - 1] === 'lib') p = p.slice(0, -1);
464
464
  prefix = p.join(path.sep) || null;
465
465
  }
466
- const args = ['install', '-g', 'kasy-cli@latest'];
467
- if (prefix) args.push('--prefix', prefix);
468
- const result = spawnSync('npm', args, { stdio: 'inherit', shell: true });
466
+ // Build a single, pre-quoted command string. Passing a quoted string
467
+ // (instead of an args array) with shell:true keeps a prefix that contains
468
+ // spaces intact e.g. C:\Users\John Silva\.kasy which the array form
469
+ // splits into a broken path (npm reads "Silva\.kasy" as a second package).
470
+ // It also avoids Node's DEP0190 arg-concatenation warning.
471
+ const quote = (s) => `"${s}"`;
472
+ let cmd = 'npm install -g kasy-cli@latest';
473
+ if (prefix) cmd += ` --prefix ${quote(prefix)}`;
474
+ const result = spawnSync(cmd, { stdio: 'inherit', shell: true });
469
475
  if (result.status === 0) {
470
476
  const after = readVersion();
471
477
  let msg;
@@ -486,9 +492,25 @@ function buildProgram(language) {
486
492
  .description(t('cli.command.uninstall.description'))
487
493
  .action(() => {
488
494
  const { spawnSync } = require('node:child_process');
495
+ const path = require('node:path');
489
496
  printCompactHeader(t);
490
497
  console.log(kleur.cyan(t('cli.command.uninstall.running')) + '\n');
491
- const result = spawnSync('npm', ['uninstall', '-g', 'kasy-cli'], { stdio: 'inherit', shell: true });
498
+ // Remove from the SAME prefix the CLI was installed into (~/.kasy), the
499
+ // way `kasy upgrade` derives it — otherwise npm targets its default global
500
+ // prefix and leaves the real copy in place. Quote it so a path with
501
+ // spaces (C:\Users\John Silva\.kasy) survives shell:true.
502
+ const segs = __dirname.split(path.sep);
503
+ const nm = segs.lastIndexOf('node_modules');
504
+ let prefix = null;
505
+ if (nm > 0) {
506
+ let p = segs.slice(0, nm);
507
+ if (p[p.length - 1] === 'lib') p = p.slice(0, -1);
508
+ prefix = p.join(path.sep) || null;
509
+ }
510
+ const quote = (s) => `"${s}"`;
511
+ let cmd = 'npm uninstall -g kasy-cli';
512
+ if (prefix) cmd += ` --prefix ${quote(prefix)}`;
513
+ const result = spawnSync(cmd, { stdio: 'inherit', shell: true });
492
514
  if (result.status === 0) {
493
515
  console.log('\n' + kleur.green('✓ ' + t('cli.command.uninstall.done')) + '\n');
494
516
  } else {
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.23.0",
3
+ "version": "1.25.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"
@@ -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
  }
@@ -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,
@@ -746,6 +748,7 @@ class _KasySidebarState extends State<KasySidebar> {
746
748
  hoverColor: c.activeBg,
747
749
  pressColor: c.textActive,
748
750
  focusable: true,
751
+ focusGapColor: c.bg,
749
752
  onTap: widget.onProfileTap ?? () {},
750
753
  child: Padding(
751
754
  padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
@@ -877,6 +880,7 @@ class _KasySidebarState extends State<KasySidebar> {
877
880
  hoverColor: c.activeBg,
878
881
  pressColor: c.textActive,
879
882
  focusable: true,
883
+ focusGapColor: c.bg,
880
884
  onTap: onTap,
881
885
  child: Container(
882
886
  constraints: const BoxConstraints(minHeight: _kItemMinH),
@@ -947,6 +951,7 @@ class _KasySidebarState extends State<KasySidebar> {
947
951
  hoverColor: c.activeBg,
948
952
  pressColor: c.textActive,
949
953
  focusable: true,
954
+ focusGapColor: c.bg,
950
955
  onTap: () => _activateItem(item.id),
951
956
  child: Container(
952
957
  constraints: const BoxConstraints(minHeight: _kItemMinH),
@@ -1068,6 +1073,8 @@ class _KasySidebarState extends State<KasySidebar> {
1068
1073
  borderRadius: BorderRadius.circular(_kItemRadius),
1069
1074
  hoverColor: c.activeBg,
1070
1075
  pressColor: c.textActive,
1076
+ focusable: true,
1077
+ focusGapColor: c.bg,
1071
1078
  onTap: () => _activateSubItem(label),
1072
1079
  child: Container(
1073
1080
  height: _kSubItemH,
@@ -1295,6 +1302,8 @@ class _ProTooltipIconState extends State<_ProTooltipIcon> {
1295
1302
  borderRadius: BorderRadius.circular(_kItemRadius),
1296
1303
  hoverColor: widget.activeBg,
1297
1304
  pressColor: widget.activeBg,
1305
+ focusable: true,
1306
+ focusGapColor: widget.colors.bg,
1298
1307
  onTap: widget.onTap,
1299
1308
  child: Container(
1300
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
@@ -152,20 +152,27 @@ class BottomMenu extends StatelessWidget {
152
152
  }
153
153
  }
154
154
 
155
- /// Owns the sidebar's keyboard [FocusScope] and keeps the initial Tab focus
156
- /// anchored on the sidebar — on every screen, like Stripe/Linear.
155
+ /// Keeps the initial keyboard Tab focus anchored on the sidebar on every
156
+ /// screen, like Stripe/Linear.
157
157
  ///
158
158
  /// Why this exists: Bart renders each page inside a nested [Navigator]
159
159
  /// (see bart's nested_navigator.dart), which has its OWN FocusScope and claims
160
160
  /// the primary focus the moment a route mounts. A plain `autofocus` on a sidebar
161
161
  /// item loses that race — the Navigator overwrites it in the same frame.
162
162
  ///
163
- /// The fix is a post-frame callback (runs AFTER the Navigator has claimed focus)
164
- /// that hands focus back to this sidebar scope. We focus the SCOPE, not a
165
- /// specific item, so the very first Tab lands on the first item (no item is
166
- /// skipped). It re-runs whenever [currentItem] changes (a tab navigation) so a
167
- /// fresh screen also starts at the sidebar. The ring still only paints during
168
- /// keyboard navigation, so this is invisible to mouse/touch users.
163
+ /// The fix is a tiny non-traversable [Focus] anchor inside the sidebar. A
164
+ /// post-frame callback (runs AFTER the Navigator has claimed focus) moves focus
165
+ /// to that anchor, pulling the primary focus out of the Navigator's scope and
166
+ /// back onto the sidebar. Because the anchor sets `skipTraversal: true`, it is
167
+ /// skipped by Tab, so the very first Tab lands on the first real sidebar item
168
+ /// and then flows on to the header and content — nothing is trapped. (A
169
+ /// [FocusScope] would have worked for the anchoring, but it adds a traversal
170
+ /// boundary that wraps Tab back to the sidebar's start instead of moving on to
171
+ /// the content, which is the opposite of what we want.)
172
+ ///
173
+ /// It re-anchors whenever [currentItem] changes (a tab navigation) so a fresh
174
+ /// screen also starts at the sidebar. The ring still only paints during keyboard
175
+ /// navigation, so this is invisible to mouse/touch users.
169
176
  class _FocusableSidebar extends StatefulWidget {
170
177
  final Widget child;
171
178
  final ValueNotifier<int> currentItem;
@@ -177,7 +184,10 @@ class _FocusableSidebar extends StatefulWidget {
177
184
  }
178
185
 
179
186
  class _FocusableSidebarState extends State<_FocusableSidebar> {
180
- final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'sidebarScope');
187
+ final FocusNode _anchor = FocusNode(
188
+ debugLabel: 'sidebarFocusAnchor',
189
+ skipTraversal: true,
190
+ );
181
191
 
182
192
  @override
183
193
  void initState() {
@@ -199,26 +209,28 @@ class _FocusableSidebarState extends State<_FocusableSidebar> {
199
209
  // which claims focus for its own scope while the route is mounting.
200
210
  void _anchorFocus() {
201
211
  WidgetsBinding.instance.addPostFrameCallback((_) {
202
- if (!mounted) return;
203
- // Only seize focus when nothing inside the sidebar already holds it, so a
204
- // keyboard user mid-navigation in the sidebar isn't yanked back to start.
205
- if (_scope.hasFocus) return;
206
- _scope.requestFocus();
212
+ if (mounted) _anchor.requestFocus();
207
213
  });
208
214
  }
209
215
 
210
216
  @override
211
217
  void dispose() {
212
218
  widget.currentItem.removeListener(_anchorFocus);
213
- _scope.dispose();
219
+ _anchor.dispose();
214
220
  super.dispose();
215
221
  }
216
222
 
217
223
  @override
218
224
  Widget build(BuildContext context) {
219
- return FocusScope(
220
- node: _scope,
221
- child: FocusTraversalGroup(child: widget.child),
225
+ 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).
228
+ child: Stack(
229
+ children: [
230
+ widget.child,
231
+ Focus(focusNode: _anchor, child: const SizedBox.shrink()),
232
+ ],
233
+ ),
222
234
  );
223
235
  }
224
236
  }
@@ -27,12 +27,20 @@ class KasyFocusRing extends StatefulWidget {
27
27
  /// When false the ring never takes focus or paints (disabled controls).
28
28
  final bool enabled;
29
29
 
30
+ /// Colour of the hair-line gap between the control and the ring. Defaults to
31
+ /// [KasyColors.background] (correct on full-page backgrounds). Pass the actual
32
+ /// surface colour the control sits on (e.g. the sidebar/header `surface`) so
33
+ /// the gap blends in instead of painting a visible halo — this matters in dark
34
+ /// mode, where `background` is darker than `surface`.
35
+ final Color? gapColor;
36
+
30
37
  const KasyFocusRing({
31
38
  super.key,
32
39
  required this.child,
33
40
  required this.borderRadius,
34
41
  this.onActivate,
35
42
  this.enabled = true,
43
+ this.gapColor,
36
44
  });
37
45
 
38
46
  @override
@@ -57,7 +65,7 @@ class _KasyFocusRingState extends State<KasyFocusRing> {
57
65
  // A hair-line gap in the canvas colour lifts the ring just off the control
58
66
  // so it reads as a crisp outline (same focus language as KasyTextField) and
59
67
  // stays visible even on primary-coloured buttons (e.g. the chat send orb).
60
- final Color gapColor = context.colors.background;
68
+ final Color gapColor = widget.gapColor ?? context.colors.background;
61
69
  final VoidCallback? onActivate = widget.onActivate;
62
70
 
63
71
  return FocusableActionDetector(
@@ -50,6 +50,7 @@ class KasyHover extends ConsumerStatefulWidget {
50
50
  this.hoverColor,
51
51
  this.pressColor,
52
52
  this.focusable = false,
53
+ this.focusGapColor,
53
54
  });
54
55
 
55
56
  final Widget child;
@@ -112,6 +113,12 @@ class KasyHover extends ConsumerStatefulWidget {
112
113
  /// call sites stay plain, non-focusable rows.
113
114
  final bool focusable;
114
115
 
116
+ /// Gap colour forwarded to the focus ring (only used when [focusable]). Pass
117
+ /// the surface colour the row sits on so the keyboard ring's hair-line gap
118
+ /// blends in instead of showing a darker halo (notably in dark mode). When
119
+ /// null the ring falls back to [KasyColors.background].
120
+ final Color? focusGapColor;
121
+
115
122
  @override
116
123
  ConsumerState<KasyHover> createState() => _KasyHoverState();
117
124
  }
@@ -209,6 +216,7 @@ class _KasyHoverState extends ConsumerState<KasyHover> {
209
216
  ? KasyFocusRing(
210
217
  borderRadius: widget.borderRadius,
211
218
  onActivate: widget.onTap,
219
+ gapColor: widget.focusGapColor,
212
220
  child: content,
213
221
  )
214
222
  : content;
@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
6
6
  import 'package:flutter/services.dart';
7
7
  import 'package:flutter_riverpod/flutter_riverpod.dart';
8
8
  import 'package:kasy_kit/core/haptics/haptic_feedback_notifier.dart';
9
+ import 'package:kasy_kit/core/theme/theme.dart';
10
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
9
11
 
10
12
  /// Press-in with slight overshoot-back (same tactility as [KasyAlert] actions).
11
13
  ///
@@ -26,6 +28,22 @@ class KasyPressableDepth extends ConsumerStatefulWidget {
26
28
  /// If false, does not call [HapticFeedback.lightImpact] on tap.
27
29
  final bool hapticFeedbackEnabled;
28
30
 
31
+ /// When true, the control becomes a keyboard tab-stop: it is wrapped in a
32
+ /// [KasyFocusRing] so Tab shows the focus outline and Enter/Space activate it.
33
+ /// Defaults to false because [KasyButton] already wraps its own ring around
34
+ /// the child — turning this on there would paint a second ring. Use it for
35
+ /// direct, standalone uses (e.g. a text link) that need keyboard access.
36
+ final bool focusable;
37
+
38
+ /// Corner radius for the focus ring (only used when [focusable]). Defaults to
39
+ /// [clipBorderRadius] when set, otherwise a small radius hugging the visual.
40
+ final BorderRadius? focusBorderRadius;
41
+
42
+ /// Gap colour for the focus ring (only used when [focusable]). Pass the
43
+ /// surface the control sits on so the ring's hair-line gap blends in instead
44
+ /// of showing a halo (notably in dark mode). Null falls back to `background`.
45
+ final Color? focusGapColor;
46
+
29
47
  const KasyPressableDepth({
30
48
  super.key,
31
49
  required this.child,
@@ -34,6 +52,9 @@ class KasyPressableDepth extends ConsumerStatefulWidget {
34
52
  this.pressOverlayColor,
35
53
  this.clipBorderRadius,
36
54
  this.hapticFeedbackEnabled = true,
55
+ this.focusable = false,
56
+ this.focusBorderRadius,
57
+ this.focusGapColor,
37
58
  });
38
59
 
39
60
  @override
@@ -142,6 +163,21 @@ class _KasyPressableDepthState extends ConsumerState<KasyPressableDepth>
142
163
 
143
164
  @override
144
165
  Widget build(BuildContext context) {
166
+ // The ring wraps the scaled visual (not the 44px tap target) so it hugs the
167
+ // control's shape, and sits outside Transform.scale so it doesn't pulse with
168
+ // the press. Keyboard activation is wired to the same handler as the tap.
169
+ Widget visual = _wrapScaledChild(widget.child);
170
+ if (widget.focusable) {
171
+ visual = KasyFocusRing(
172
+ onActivate: _handleTap,
173
+ borderRadius: widget.focusBorderRadius ??
174
+ widget.clipBorderRadius ??
175
+ BorderRadius.circular(KasyRadius.sm),
176
+ gapColor: widget.focusGapColor,
177
+ child: visual,
178
+ );
179
+ }
180
+
145
181
  final Widget inner = Semantics(
146
182
  button: true,
147
183
  enabled: true,
@@ -153,7 +189,7 @@ class _KasyPressableDepthState extends ConsumerState<KasyPressableDepth>
153
189
  onTap: _handleTap,
154
190
  child: ConstrainedBox(
155
191
  constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
156
- child: Center(child: _wrapScaledChild(widget.child)),
192
+ child: Center(child: visual),
157
193
  ),
158
194
  ),
159
195
  );
@@ -124,6 +124,7 @@ class _BackToSigninPrompt extends StatelessWidget {
124
124
  context.go('/signin');
125
125
  }
126
126
  },
127
+ focusable: true,
127
128
  child: Text(
128
129
  t.auth.recover.signin_link,
129
130
  style: context.textTheme.bodyMedium?.copyWith(
@@ -98,11 +98,15 @@ class SigninPage extends ConsumerWidget {
98
98
  FocusScope.of(context).unfocus();
99
99
  ref.read(signinStateProvider.notifier).signin();
100
100
  },
101
- labelTrailing: GestureDetector(
102
- onTap: () => context.push('/recover_password'),
103
- child: Text(
104
- t.auth.signin.forgot_password,
105
- style: forgotPasswordStyle,
101
+ labelTrailing: KasyFocusRing(
102
+ onActivate: () => context.push('/recover_password'),
103
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
104
+ child: GestureDetector(
105
+ onTap: () => context.push('/recover_password'),
106
+ child: Text(
107
+ t.auth.signin.forgot_password,
108
+ style: forgotPasswordStyle,
109
+ ),
106
110
  ),
107
111
  ),
108
112
  validator: (value) {
@@ -191,6 +195,7 @@ class _SignupPrompt extends StatelessWidget {
191
195
  KasyPressableDepth(
192
196
  semanticLabel: t.auth.signin.signup_link,
193
197
  onPressed: () => context.pushReplacement('/signup'),
198
+ focusable: true,
194
199
  child: Text(
195
200
  t.auth.signin.signup_link,
196
201
  style: context.textTheme.bodyMedium?.copyWith(
@@ -180,6 +180,7 @@ class _SigninPrompt extends StatelessWidget {
180
180
  KasyPressableDepth(
181
181
  semanticLabel: t.auth.signup.signin_link,
182
182
  onPressed: () => context.pushReplacement('/signin'),
183
+ focusable: true,
183
184
  child: Text(
184
185
  t.auth.signup.signin_link,
185
186
  style: context.textTheme.bodyMedium?.copyWith(
@@ -12,6 +12,7 @@ import 'package:kasy_kit/core/security/biometric_ui_bundle.dart';
12
12
  import 'package:kasy_kit/core/states/logout_action.dart';
13
13
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
14
14
  import 'package:kasy_kit/core/theme/theme.dart';
15
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
15
16
  import 'package:kasy_kit/core/widgets/kasy_hover.dart';
16
17
  import 'package:kasy_kit/features/settings/ui/components/avatar_component.dart';
17
18
  import 'package:kasy_kit/features/settings/ui/components/delete_user_component.dart';
@@ -329,7 +330,18 @@ class ProfileTile extends StatelessWidget {
329
330
  child: ClipRRect(
330
331
  borderRadius: KasyRadius.smBorderRadius,
331
332
  child: onTap != null
332
- ? InkWell(onTap: onTap, child: content)
333
+ ? KasyFocusRing(
334
+ onActivate: onTap,
335
+ borderRadius: KasyRadius.smBorderRadius,
336
+ gapColor: context.colors.surface,
337
+ // The InkWell keeps its tap ripple but yields focus to the ring,
338
+ // so the keyboard outline matches every other Kasy control.
339
+ child: InkWell(
340
+ canRequestFocus: false,
341
+ onTap: onTap,
342
+ child: content,
343
+ ),
344
+ )
333
345
  : content,
334
346
  ),
335
347
  );
@@ -547,6 +559,8 @@ class _NavTile extends StatelessWidget {
547
559
  borderRadius: KasyRadius.smBorderRadius,
548
560
  hoverColor: c.surfaceNeutralSoft,
549
561
  pressColor: c.onSurface,
562
+ focusable: true,
563
+ focusGapColor: c.surface,
550
564
  onTap: onTap,
551
565
  child: Container(
552
566
  padding: const EdgeInsets.symmetric(
@@ -770,6 +784,8 @@ class _LogoutRow extends StatelessWidget {
770
784
  return KasyHover(
771
785
  borderRadius: KasyRadius.smBorderRadius,
772
786
  pressColor: context.colors.error,
787
+ focusable: true,
788
+ focusGapColor: context.colors.surface,
773
789
  onTap: onTap,
774
790
  child: Padding(
775
791
  padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
5
5
  import 'package:kasy_kit/core/home_widgets/home_widget_mywidget_service.dart';
6
6
  import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
7
7
  import 'package:kasy_kit/core/theme/theme.dart';
8
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
8
9
  import 'package:kasy_kit/features/settings/ui/widgets/settings_tile.dart';
9
10
  import 'package:kasy_kit/i18n/app_locale_display.dart';
10
11
  import 'package:kasy_kit/i18n/translations.g.dart';
@@ -17,31 +18,40 @@ class LanguageSwitcher extends ConsumerWidget {
17
18
  @override
18
19
  Widget build(BuildContext context, WidgetRef ref) {
19
20
  final AppLocale current = TranslationProvider.of(context).locale;
20
- return InkWell(
21
- onTap: () => _showLanguagePicker(context, ref, current),
22
- child: Padding(
23
- padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
24
- child: Row(
25
- children: <Widget>[
26
- Icon(KasyIcons.language, size: 21, color: context.colors.onSurface),
27
- const SizedBox(width: KasySpacing.sm),
28
- Expanded(
29
- child: Text(
30
- context.t.settings.language_title,
31
- style: context.textTheme.titleMedium?.copyWith(
32
- color: context.colors.onSurface,
21
+ return KasyFocusRing(
22
+ onActivate: () => _showLanguagePicker(context, ref, current),
23
+ borderRadius: KasyRadius.smBorderRadius,
24
+ child: InkWell(
25
+ canRequestFocus: false,
26
+ onTap: () => _showLanguagePicker(context, ref, current),
27
+ child: Padding(
28
+ padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
29
+ child: Row(
30
+ children: <Widget>[
31
+ Icon(
32
+ KasyIcons.language,
33
+ size: 21,
34
+ color: context.colors.onSurface,
35
+ ),
36
+ const SizedBox(width: KasySpacing.sm),
37
+ Expanded(
38
+ child: Text(
39
+ context.t.settings.language_title,
40
+ style: context.textTheme.titleMedium?.copyWith(
41
+ color: context.colors.onSurface,
42
+ ),
33
43
  ),
34
44
  ),
35
- ),
36
- Text(
37
- current.nativeName,
38
- style: context.textTheme.bodyMedium?.copyWith(
39
- color: context.colors.muted,
45
+ Text(
46
+ current.nativeName,
47
+ style: context.textTheme.bodyMedium?.copyWith(
48
+ color: context.colors.muted,
49
+ ),
40
50
  ),
41
- ),
42
- const SizedBox(width: KasySpacing.xs),
43
- const SettingsListChevron(),
44
- ],
51
+ const SizedBox(width: KasySpacing.xs),
52
+ const SettingsListChevron(),
53
+ ],
54
+ ),
45
55
  ),
46
56
  ),
47
57
  );
@@ -147,36 +157,41 @@ class _LocaleOptionTile extends StatelessWidget {
147
157
  final Color primary = context.colors.primary;
148
158
  final Color fg = isSelected ? primary : context.colors.onSurface;
149
159
 
150
- return InkWell(
151
- onTap: onTap,
160
+ return KasyFocusRing(
161
+ onActivate: onTap,
152
162
  borderRadius: BorderRadius.circular(KasyRadius.sm),
153
- child: Padding(
154
- padding: const EdgeInsets.symmetric(
155
- horizontal: KasySpacing.xs,
156
- vertical: KasySpacing.smd,
157
- ),
158
- child: Row(
159
- children: <Widget>[
160
- Expanded(
161
- child: Text(
162
- locale.nativeName,
163
- style: context.textTheme.bodyLarge?.copyWith(
164
- color: fg,
165
- fontWeight:
166
- isSelected ? FontWeight.w600 : FontWeight.w400,
163
+ child: InkWell(
164
+ canRequestFocus: false,
165
+ onTap: onTap,
166
+ borderRadius: BorderRadius.circular(KasyRadius.sm),
167
+ child: Padding(
168
+ padding: const EdgeInsets.symmetric(
169
+ horizontal: KasySpacing.xs,
170
+ vertical: KasySpacing.smd,
171
+ ),
172
+ child: Row(
173
+ children: <Widget>[
174
+ Expanded(
175
+ child: Text(
176
+ locale.nativeName,
177
+ style: context.textTheme.bodyLarge?.copyWith(
178
+ color: fg,
179
+ fontWeight:
180
+ isSelected ? FontWeight.w600 : FontWeight.w400,
181
+ ),
167
182
  ),
168
183
  ),
169
- ),
170
- if (isSelected)
171
- Container(
172
- width: 10,
173
- height: 10,
174
- decoration: BoxDecoration(
175
- color: primary,
176
- shape: BoxShape.circle,
184
+ if (isSelected)
185
+ Container(
186
+ width: 10,
187
+ height: 10,
188
+ decoration: BoxDecoration(
189
+ color: primary,
190
+ shape: BoxShape.circle,
191
+ ),
177
192
  ),
178
- ),
179
- ],
193
+ ],
194
+ ),
180
195
  ),
181
196
  ),
182
197
  );
@@ -157,6 +157,8 @@ class SettingsTile extends StatelessWidget {
157
157
  onTap: onTap,
158
158
  hoverEnabled: false,
159
159
  pressEnabled: false,
160
+ focusable: true,
161
+ borderRadius: KasyRadius.smBorderRadius,
160
162
  semanticLabel: title,
161
163
  padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
162
164
  child: Row(