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
@@ -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
+ }
@@ -3,19 +3,57 @@ import 'package:kasy_kit/components/kasy_web_header.dart';
3
3
  import 'package:kasy_kit/core/theme/theme.dart';
4
4
  import 'package:kasy_kit/core/widgets/responsive_layout.dart';
5
5
 
6
+ /// Focus target of the currently mounted content, exposed to the "skip to
7
+ /// content" link (which lives in the shell, outside the routed page).
8
+ ///
9
+ /// Each [WebContentWrapper] owns its OWN [FocusNode] and publishes it here on
10
+ /// mount, so during a route transition (where the old and new page briefly
11
+ /// coexist) there is never a single node attached to two widgets. The link
12
+ /// always jumps to the most recently mounted page.
13
+ FocusNode? kasyContentFocusTarget;
14
+
6
15
  /// On desktop (≥ [DeviceType.large]) this puts the [KasyWebHeader] at the top of
7
16
  /// the content area, above the routed page. On phone/tablet it is transparent
8
17
  /// (returns the page untouched) — there the page keeps its own [KasyAppBar].
9
18
  ///
10
19
  /// Wrap each routed page with this in the bottom router so the header is present
11
20
  /// across navigation without touching individual pages.
12
- class WebContentWrapper extends StatelessWidget {
21
+ class WebContentWrapper extends StatefulWidget {
13
22
  final Widget child;
14
23
 
15
24
  const WebContentWrapper({super.key, required this.child});
16
25
 
26
+ @override
27
+ State<WebContentWrapper> createState() => _WebContentWrapperState();
28
+ }
29
+
30
+ class _WebContentWrapperState extends State<WebContentWrapper> {
31
+ // skipTraversal: the "skip to content" link focuses this node directly, but it
32
+ // stays out of the Tab order (like a tabindex="-1" skip target on the web) so
33
+ // the next Tab lands on the first real control inside the content.
34
+ final FocusNode _contentFocus = FocusNode(
35
+ debugLabel: 'skipToContentTarget',
36
+ skipTraversal: true,
37
+ );
38
+
39
+ @override
40
+ void initState() {
41
+ super.initState();
42
+ kasyContentFocusTarget = _contentFocus;
43
+ }
44
+
45
+ @override
46
+ void dispose() {
47
+ if (kasyContentFocusTarget == _contentFocus) {
48
+ kasyContentFocusTarget = null;
49
+ }
50
+ _contentFocus.dispose();
51
+ super.dispose();
52
+ }
53
+
17
54
  @override
18
55
  Widget build(BuildContext context) {
56
+ final Widget child = widget.child;
19
57
  final bool isDesktop =
20
58
  MediaQuery.sizeOf(context).width >= DeviceType.large.breakpoint;
21
59
  if (!isDesktop) return child;
@@ -44,7 +82,12 @@ class WebContentWrapper extends StatelessWidget {
44
82
  Expanded(
45
83
  child: FocusTraversalOrder(
46
84
  order: const NumericFocusOrder(3),
47
- child: FocusTraversalGroup(child: child),
85
+ child: FocusTraversalGroup(
86
+ // Focus target for "skip to content": focusing it moves the
87
+ // primary focus into the content; the next Tab then steps to the
88
+ // first real control here.
89
+ child: Focus(focusNode: _contentFocus, child: child),
90
+ ),
48
91
  ),
49
92
  ),
50
93
  ],
@@ -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
  );
@@ -5,6 +5,7 @@ import 'package:intl/intl.dart';
5
5
  import 'package:kasy_kit/core/data/models/user.dart';
6
6
  import 'package:kasy_kit/core/states/user_state_notifier.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/ai_chat/api/ai_chat_conversation_entity.dart';
9
10
  import 'package:kasy_kit/features/ai_chat/ui/widgets/ai_chat_avatars.dart';
10
11
  import 'package:kasy_kit/i18n/translations.g.dart';
@@ -104,10 +105,14 @@ class _AiConversationTileState extends ConsumerState<AiConversationTile> {
104
105
  button: true,
105
106
  selected: widget.selected,
106
107
  label: title,
107
- child: GestureDetector(
108
- behavior: HitTestBehavior.opaque,
109
- onTap: widget.onTap,
110
- child: tile,
108
+ child: KasyFocusRing(
109
+ onActivate: widget.onTap,
110
+ borderRadius: BorderRadius.circular(16),
111
+ child: GestureDetector(
112
+ behavior: HitTestBehavior.opaque,
113
+ onTap: widget.onTap,
114
+ child: tile,
115
+ ),
111
116
  ),
112
117
  );
113
118
 
@@ -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(
@@ -20,6 +20,7 @@ class AddFeatureButton extends StatelessWidget {
20
20
  onTap: onPressed,
21
21
  borderRadius: KasyRadius.mdBorderRadius,
22
22
  pressColor: context.colors.onPrimary,
23
+ focusable: true,
23
24
  child: Container(
24
25
  padding: const EdgeInsets.symmetric(
25
26
  horizontal: KasySpacing.lg,
@@ -191,6 +191,8 @@ class _VoteCardState extends State<VoteCard>
191
191
  borderRadius: KasyRadius.smBorderRadius,
192
192
  pressColor: widget.textColor,
193
193
  hapticEnabled: false,
194
+ focusable: true,
195
+ focusGapColor: context.colors.surface,
194
196
  child: AnimatedContainer(
195
197
  duration: colorDuration,
196
198
  curve: Curves.easeOut,
@@ -199,22 +199,36 @@ class _FilterRow extends StatelessWidget {
199
199
 
200
200
  const _FilterRow({required this.selected, required this.onSelect});
201
201
 
202
+ // Breathing room around the row so the keyboard focus ring (which paints a
203
+ // few px outside the card) is never clipped by the horizontal list's edges.
204
+ static const double _ringInset = 8;
205
+
202
206
  @override
203
207
  Widget build(BuildContext context) {
204
208
  const List<HomeCategory> all = HomeCategory.values;
205
209
 
210
+ // Smaller, denser cards on phones; full size on tablet/desktop.
211
+ final bool isMobile = MediaQuery.sizeOf(context).width < 768;
212
+ final double thumbSize = isMobile ? 56 : 79;
213
+ final double cardWidth = isMobile ? 232 : 302;
214
+ final double cardHeight = thumbSize + KasySpacing.md * 2;
215
+
206
216
  return SizedBox(
207
- height: 111,
217
+ height: cardHeight + _ringInset * 2,
208
218
  child: ListView.separated(
209
219
  scrollDirection: Axis.horizontal,
210
- padding: const EdgeInsets.symmetric(horizontal: KasySpacing.md),
220
+ padding: const EdgeInsets.symmetric(
221
+ horizontal: KasySpacing.md,
222
+ vertical: _ringInset,
223
+ ),
211
224
  itemCount: all.length,
212
225
  separatorBuilder: (_, _) => const SizedBox(width: KasySpacing.md),
213
226
  itemBuilder: (BuildContext context, int i) => SizedBox(
214
- width: 302,
227
+ width: cardWidth,
215
228
  child: _FilterCard(
216
229
  category: all[i],
217
230
  selected: all[i] == selected,
231
+ thumbSize: thumbSize,
218
232
  onTap: () => onSelect(all[i]),
219
233
  ),
220
234
  ),
@@ -226,11 +240,13 @@ class _FilterRow extends StatelessWidget {
226
240
  class _FilterCard extends StatelessWidget {
227
241
  final HomeCategory category;
228
242
  final bool selected;
243
+ final double thumbSize;
229
244
  final VoidCallback onTap;
230
245
 
231
246
  const _FilterCard({
232
247
  required this.category,
233
248
  required this.selected,
249
+ required this.thumbSize,
234
250
  required this.onTap,
235
251
  });
236
252
 
@@ -250,8 +266,8 @@ class _FilterCard extends StatelessWidget {
250
266
  ClipRRect(
251
267
  borderRadius: BorderRadius.circular(KasyRadius.lg),
252
268
  child: SizedBox(
253
- width: 79,
254
- height: 79,
269
+ width: thumbSize,
270
+ height: thumbSize,
255
271
  child: KasyNetworkImage(url: _thumbUrl(data.thumbnailId)),
256
272
  ),
257
273
  ),
@@ -6,6 +6,7 @@ import 'package:kasy_kit/components/kasy_image_viewer.dart';
6
6
  import 'package:kasy_kit/components/kasy_skeleton.dart';
7
7
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
8
8
  import 'package:kasy_kit/core/theme/theme.dart';
9
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
9
10
 
10
11
  /// A single image shown in the masonry feed.
11
12
  class HomePhoto {
@@ -22,7 +23,11 @@ class HomePhoto {
22
23
  /// Network image with a synced shimmer skeleton while loading and a graceful
23
24
  /// fallback on error. Adapts to light/dark via [KasySkeleton]'s own palette.
24
25
  class KasyNetworkImage extends StatelessWidget {
25
- const KasyNetworkImage({super.key, required this.url, this.fit = BoxFit.cover});
26
+ const KasyNetworkImage({
27
+ super.key,
28
+ required this.url,
29
+ this.fit = BoxFit.cover,
30
+ });
26
31
 
27
32
  final String url;
28
33
  final BoxFit fit;
@@ -33,24 +38,18 @@ class KasyNetworkImage extends StatelessWidget {
33
38
  url,
34
39
  fit: fit,
35
40
  gaplessPlayback: true,
36
- loadingBuilder: (
37
- BuildContext context,
38
- Widget child,
39
- ImageChunkEvent? progress,
40
- ) {
41
- if (progress == null) return child;
42
- return const _ImageSkeleton();
43
- },
44
- errorBuilder: (
45
- BuildContext context,
46
- Object error,
47
- StackTrace? stackTrace,
48
- ) {
49
- return ColoredBox(
50
- color: context.colors.surfaceNeutralSoft,
51
- child: Icon(KasyIcons.gallery, color: context.colors.muted),
52
- );
53
- },
41
+ loadingBuilder:
42
+ (BuildContext context, Widget child, ImageChunkEvent? progress) {
43
+ if (progress == null) return child;
44
+ return const _ImageSkeleton();
45
+ },
46
+ errorBuilder:
47
+ (BuildContext context, Object error, StackTrace? stackTrace) {
48
+ return ColoredBox(
49
+ color: context.colors.surfaceNeutralSoft,
50
+ child: Icon(KasyIcons.gallery, color: context.colors.muted),
51
+ );
52
+ },
54
53
  );
55
54
  }
56
55
  }
@@ -80,7 +79,16 @@ class HomeImageGrid extends StatelessWidget {
80
79
 
81
80
  static const double _gap = KasySpacing.smd;
82
81
  static const List<double> _ratios = <double>[
83
- 0.72, 1.0, 0.8, 1.32, 0.75, 1.0, 0.7, 1.25, 0.85, 1.4,
82
+ 0.72,
83
+ 1.0,
84
+ 0.8,
85
+ 1.32,
86
+ 0.75,
87
+ 1.0,
88
+ 0.7,
89
+ 1.25,
90
+ 0.85,
91
+ 1.4,
84
92
  ];
85
93
 
86
94
  int _columnsFor(double width) {
@@ -97,8 +105,10 @@ class HomeImageGrid extends StatelessWidget {
97
105
  final int columns = _columnsFor(width);
98
106
  final double columnWidth = (width - _gap * (columns - 1)) / columns;
99
107
 
100
- final List<List<Widget>> buckets =
101
- List<List<Widget>>.generate(columns, (_) => <Widget>[]);
108
+ final List<List<Widget>> buckets = List<List<Widget>>.generate(
109
+ columns,
110
+ (_) => <Widget>[],
111
+ );
102
112
  final List<double> heights = List<double>.filled(columns, 0);
103
113
 
104
114
  for (int i = 0; i < photos.length; i++) {
@@ -110,8 +120,9 @@ class HomeImageGrid extends StatelessWidget {
110
120
  if (buckets[shortest].isNotEmpty) {
111
121
  buckets[shortest].add(const SizedBox(height: _gap));
112
122
  }
113
- buckets[shortest]
114
- .add(_PhotoTile(photo: photos[i], aspectRatio: ratio));
123
+ buckets[shortest].add(
124
+ _PhotoTile(photo: photos[i], aspectRatio: ratio),
125
+ );
115
126
  heights[shortest] += columnWidth / ratio + _gap;
116
127
  }
117
128
 
@@ -170,12 +181,16 @@ class _PhotoTileState extends State<_PhotoTile> {
170
181
  // Tapping the photo opens the full-screen viewer; the Hero gives a
171
182
  // smooth zoom into and out of it.
172
183
  Positioned.fill(
173
- child: GestureDetector(
174
- onTap: _openViewer,
175
- behavior: HitTestBehavior.opaque,
176
- child: Hero(
177
- tag: photo.id,
178
- child: KasyNetworkImage(url: photo.url),
184
+ child: KasyFocusRing(
185
+ onActivate: _openViewer,
186
+ borderRadius: BorderRadius.circular(KasyRadius.xl),
187
+ child: GestureDetector(
188
+ onTap: _openViewer,
189
+ behavior: HitTestBehavior.opaque,
190
+ child: Hero(
191
+ tag: photo.id,
192
+ child: KasyNetworkImage(url: photo.url),
193
+ ),
179
194
  ),
180
195
  ),
181
196
  ),
@@ -291,13 +306,17 @@ class _LikeButtonState extends State<_LikeButton>
291
306
  );
292
307
  _scale = TweenSequence<double>(<TweenSequenceItem<double>>[
293
308
  TweenSequenceItem<double>(
294
- tween: Tween<double>(begin: 1.0, end: 1.35)
295
- .chain(CurveTween(curve: Curves.easeOut)),
309
+ tween: Tween<double>(
310
+ begin: 1.0,
311
+ end: 1.35,
312
+ ).chain(CurveTween(curve: Curves.easeOut)),
296
313
  weight: 40,
297
314
  ),
298
315
  TweenSequenceItem<double>(
299
- tween: Tween<double>(begin: 1.35, end: 1.0)
300
- .chain(CurveTween(curve: Curves.elasticOut)),
316
+ tween: Tween<double>(
317
+ begin: 1.35,
318
+ end: 1.0,
319
+ ).chain(CurveTween(curve: Curves.elasticOut)),
301
320
  weight: 60,
302
321
  ),
303
322
  ]).animate(_controller);
@@ -321,30 +340,36 @@ class _LikeButtonState extends State<_LikeButton>
321
340
  @override
322
341
  Widget build(BuildContext context) {
323
342
  final bool dark = context.isDark;
324
- final Color heart =
325
- widget.liked ? context.colors.danger : context.colors.primary;
326
-
327
- return GestureDetector(
328
- onTap: widget.onTap,
329
- behavior: HitTestBehavior.opaque,
330
- child: ClipOval(
331
- child: BackdropFilter(
332
- filter: ui.ImageFilter.blur(sigmaX: 8, sigmaY: 8),
333
- child: Container(
334
- width: 36,
335
- height: 36,
336
- alignment: Alignment.center,
337
- decoration: BoxDecoration(
338
- color: context.colors.surface
339
- .withValues(alpha: dark ? 0.5 : 0.7),
340
- shape: BoxShape.circle,
341
- ),
342
- child: ScaleTransition(
343
- scale: _scale,
344
- child: Icon(
345
- widget.liked ? KasyIcons.favoriteFilled : KasyIcons.favorite,
346
- size: 18,
347
- color: heart,
343
+ final Color heart = widget.liked
344
+ ? context.colors.danger
345
+ : context.colors.primary;
346
+
347
+ return KasyFocusRing(
348
+ onActivate: widget.onTap,
349
+ borderRadius: BorderRadius.circular(999),
350
+ child: GestureDetector(
351
+ onTap: widget.onTap,
352
+ behavior: HitTestBehavior.opaque,
353
+ child: ClipOval(
354
+ child: BackdropFilter(
355
+ filter: ui.ImageFilter.blur(sigmaX: 8, sigmaY: 8),
356
+ child: Container(
357
+ width: 36,
358
+ height: 36,
359
+ alignment: Alignment.center,
360
+ decoration: BoxDecoration(
361
+ color: context.colors.surface.withValues(
362
+ alpha: dark ? 0.5 : 0.7,
363
+ ),
364
+ shape: BoxShape.circle,
365
+ ),
366
+ child: ScaleTransition(
367
+ scale: _scale,
368
+ child: Icon(
369
+ widget.liked ? KasyIcons.favoriteFilled : KasyIcons.favorite,
370
+ size: 18,
371
+ color: heart,
372
+ ),
348
373
  ),
349
374
  ),
350
375
  ),