kasy-cli 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/lib/commands/new.js +53 -3
  2. package/lib/utils/i18n/messages-en.js +3 -0
  3. package/lib/utils/i18n/messages-es.js +3 -0
  4. package/lib/utils/i18n/messages-pt.js +3 -0
  5. package/package.json +1 -1
  6. package/templates/firebase/lib/components/kasy_app_bar.dart +12 -6
  7. package/templates/firebase/lib/components/kasy_avatar.dart +17 -10
  8. package/templates/firebase/lib/components/kasy_checkbox.dart +24 -15
  9. package/templates/firebase/lib/components/kasy_date_picker.dart +27 -20
  10. package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +19 -14
  11. package/templates/firebase/lib/components/kasy_tabs.dart +75 -65
  12. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +103 -3
  13. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +45 -2
  14. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +9 -4
  15. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -0
  16. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +2 -0
  17. package/templates/firebase/lib/features/home/home_feed.dart +21 -5
  18. package/templates/firebase/lib/features/home/home_image_grid.dart +83 -58
  19. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +124 -101
  20. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +1 -0
  21. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +85 -85
  22. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +67 -62
  23. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +37 -23
  24. package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +14 -6
  25. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +9 -3
  26. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +24 -16
  27. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +29 -22
  28. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +19 -12
  29. package/templates/firebase/lib/i18n/en.i18n.json +2 -1
  30. package/templates/firebase/lib/i18n/es.i18n.json +2 -1
  31. package/templates/firebase/lib/i18n/pt.i18n.json +2 -1
@@ -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
  ],
@@ -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
 
@@ -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
  ),