kasy-cli 1.19.3 → 1.20.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 (43) hide show
  1. package/bin/kasy.js +1 -0
  2. package/lib/commands/new.js +9 -0
  3. package/lib/commands/run.js +7 -0
  4. package/lib/scaffold/backends/firebase/setup-from-scratch.js +91 -2
  5. package/lib/scaffold/engine.js +5 -0
  6. package/lib/scaffold/generate.js +4 -0
  7. package/lib/scaffold/shared/generator-utils.js +38 -1
  8. package/lib/utils/i18n/messages-en.js +1 -0
  9. package/lib/utils/i18n/messages-es.js +1 -0
  10. package/lib/utils/i18n/messages-pt.js +1 -0
  11. package/package.json +1 -1
  12. package/templates/firebase/android/app/src/main/res/drawable/background.png +0 -0
  13. package/templates/firebase/android/app/src/main/res/drawable-night/background.png +0 -0
  14. package/templates/firebase/android/app/src/main/res/drawable-night-v21/background.png +0 -0
  15. package/templates/firebase/android/app/src/main/res/drawable-v21/background.png +0 -0
  16. package/templates/firebase/android/app/src/main/res/values-night-v31/styles.xml +1 -1
  17. package/templates/firebase/android/app/src/main/res/values-v31/styles.xml +1 -1
  18. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png +0 -0
  19. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png +0 -0
  20. package/templates/firebase/lib/components/kasy_date_picker.dart +14 -8
  21. package/templates/firebase/lib/components/kasy_sidebar_pro.dart +1148 -0
  22. package/templates/firebase/lib/components/kasy_tabs.dart +156 -43
  23. package/templates/firebase/lib/components/kasy_text_field.dart +37 -34
  24. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +90 -69
  25. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +30 -7
  26. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +8 -1
  27. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +433 -243
  28. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +198 -83
  29. package/templates/firebase/lib/core/icons/kasy_icons.dart +1 -0
  30. package/templates/firebase/lib/core/theme/colors.dart +6 -2
  31. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +119 -19
  32. package/templates/firebase/lib/core/widgets/kasy_hover.dart +68 -27
  33. package/templates/firebase/lib/features/home/home_components_page.dart +3 -0
  34. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +121 -66
  35. package/templates/firebase/lib/features/settings/settings_page.dart +27 -146
  36. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +16 -3
  37. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +22 -5
  38. package/templates/firebase/lib/i18n/en.i18n.json +3 -1
  39. package/templates/firebase/lib/i18n/es.i18n.json +3 -1
  40. package/templates/firebase/lib/i18n/pt.i18n.json +3 -1
  41. package/templates/firebase/pubspec.yaml +6 -4
  42. package/templates/firebase/web/index.html +7 -17
  43. package/templates/firebase/lib/firebase_options.dart +0 -75
@@ -7,15 +7,24 @@ import 'package:kasy_kit/core/theme/theme.dart';
7
7
 
8
8
  /// Data model for a single tab item.
9
9
  class KasyTabItem {
10
+ /// Visible text. Leave empty for an icon-only tab (requires [icon]).
10
11
  final String label;
11
12
  final IconData? icon;
12
13
  final bool enabled;
13
14
 
15
+ /// Accessibility label read by screen readers. Falls back to [label] when
16
+ /// not provided; required (in spirit) for icon-only tabs, which have no text.
17
+ final String? semanticLabel;
18
+
14
19
  const KasyTabItem({
15
- required this.label,
20
+ this.label = '',
16
21
  this.icon,
17
22
  this.enabled = true,
18
- });
23
+ this.semanticLabel,
24
+ }) : assert(
25
+ label != '' || icon != null,
26
+ 'KasyTabItem needs a label, an icon, or both.',
27
+ );
19
28
  }
20
29
 
21
30
  // ─────────────────────────────────────────────────────────────────────────────
@@ -190,27 +199,41 @@ class _KasyTabsState extends State<KasyTabs> {
190
199
  }
191
200
  }
192
201
 
193
- /// Scrolls the [SingleChildScrollView] so the selected tab is fully visible.
202
+ /// Scrolls the inner [SingleChildScrollView] so the selected tab is visible.
194
203
  ///
195
- /// First/last tab snap to the scroll extremes so the container's outer
196
- /// padding (and the pill that extends 4px beyond tab edges) is never clipped.
204
+ /// Only the component's OWN horizontal controller is moved never an
205
+ /// ancestor scrollable. (Using [Scrollable.ensureVisible] here would bubble
206
+ /// up and scroll the enclosing page vertically, e.g. nudging the Settings
207
+ /// list down when picking a middle tab.) First/last tab snap to the scroll
208
+ /// extremes so the container padding and the 4px pill overflow aren't clipped.
197
209
  void _ensureSelectedVisible(int index) {
210
+ if (!_scrollController.hasClients) return;
211
+
212
+ final ScrollPosition position = _scrollController.position;
213
+ final double min = position.minScrollExtent;
214
+ final double max = position.maxScrollExtent;
215
+ // No horizontal overflow: leave every scroll position untouched.
216
+ if (max <= min) return;
217
+
198
218
  const Duration duration = Duration(milliseconds: 250);
199
219
  const Curve curve = Curves.easeInOut;
200
220
  final int lastIndex = widget.items.length - 1;
201
221
 
202
- if (_scrollController.hasClients &&
203
- (index == 0 || index == lastIndex)) {
204
- final double target = index == 0
205
- ? _scrollController.position.minScrollExtent
206
- : _scrollController.position.maxScrollExtent;
207
- _scrollController.animateTo(target, duration: duration, curve: curve);
208
- return;
222
+ final double target;
223
+ if (index == 0) {
224
+ target = min;
225
+ } else if (index == lastIndex) {
226
+ target = max;
227
+ } else {
228
+ // Centre the selected tab in the viewport. Measured geometry is relative
229
+ // to the inner Stack, which sits 8px in from the scrollable content edge
230
+ // (the tabsContent horizontal padding).
231
+ const double horizontalPadding = 8;
232
+ final double tabCentre =
233
+ horizontalPadding + _indicatorLeft + _indicatorWidth / 2;
234
+ target = (tabCentre - position.viewportDimension / 2).clamp(min, max);
209
235
  }
210
-
211
- final BuildContext? ctx = _keys[index].currentContext;
212
- if (ctx == null) return;
213
- Scrollable.ensureVisible(ctx, duration: duration, curve: curve);
236
+ _scrollController.animateTo(target, duration: duration, curve: curve);
214
237
  }
215
238
 
216
239
  @override
@@ -397,7 +420,31 @@ class _KasyTabsState extends State<KasyTabs> {
397
420
  // Internal tab widgets
398
421
  // ─────────────────────────────────────────────────────────────────────────────
399
422
 
400
- class _PrimaryTab extends StatelessWidget {
423
+ /// Adds a pointer (click) cursor and reports hover changes on web/desktop.
424
+ /// Disabled tabs keep the default cursor and never report hover.
425
+ class _TabHoverRegion extends StatelessWidget {
426
+ const _TabHoverRegion({
427
+ required this.enabled,
428
+ required this.onHover,
429
+ required this.child,
430
+ });
431
+
432
+ final bool enabled;
433
+ final ValueChanged<bool> onHover;
434
+ final Widget child;
435
+
436
+ @override
437
+ Widget build(BuildContext context) {
438
+ return MouseRegion(
439
+ cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer,
440
+ onEnter: enabled ? (_) => onHover(true) : null,
441
+ onExit: enabled ? (_) => onHover(false) : null,
442
+ child: child,
443
+ );
444
+ }
445
+ }
446
+
447
+ class _PrimaryTab extends StatefulWidget {
401
448
  const _PrimaryTab({
402
449
  super.key,
403
450
  required this.item,
@@ -411,12 +458,27 @@ class _PrimaryTab extends StatelessWidget {
411
458
  final bool expand;
412
459
  final VoidCallback? onTap;
413
460
 
461
+ @override
462
+ State<_PrimaryTab> createState() => _PrimaryTabState();
463
+ }
464
+
465
+ class _PrimaryTabState extends State<_PrimaryTab> {
466
+ bool _hovered = false;
467
+
414
468
  Widget _tabContent(BuildContext context) {
415
469
  final KasyColors c = context.colors;
470
+ final KasyTabItem item = widget.item;
471
+ final bool selected = widget.selected;
416
472
  final bool disabled = !item.enabled;
417
- // Fill mode with icons uses a vertical (Column) layout per Figma spec:
418
- // icon stacked above label, 12px all-sides padding, 12px font size.
419
- final bool verticalLayout = expand && item.icon != null;
473
+ final bool hasLabel = item.label.isNotEmpty;
474
+ final bool iconOnly = item.icon != null && !hasLabel;
475
+ // Fill mode with a label + icon uses a vertical (Column) layout per Figma
476
+ // spec: icon stacked above label, 12px all-sides padding, 12px font size.
477
+ final bool verticalLayout = widget.expand && item.icon != null && hasLabel;
478
+
479
+ // On web/desktop, hovering an inactive tab lifts its foreground toward the
480
+ // selected color so it reads as interactive (mobile keeps the flat look).
481
+ final Color fg = (selected || _hovered) ? c.onSurface : c.muted;
420
482
 
421
483
  final Widget iconWidget = item.icon != null
422
484
  ? Opacity(
@@ -424,7 +486,7 @@ class _PrimaryTab extends StatelessWidget {
424
486
  child: Icon(
425
487
  item.icon,
426
488
  size: 16,
427
- color: selected ? c.onSurface : c.muted,
489
+ color: fg,
428
490
  ),
429
491
  )
430
492
  : const SizedBox.shrink();
@@ -437,15 +499,15 @@ class _PrimaryTab extends StatelessWidget {
437
499
  // Use labelLarge as defined in the Kasy theme (14px/w600).
438
500
  // No fontWeight override — the theme token is the source of truth.
439
501
  style: context.textTheme.labelLarge?.copyWith(
440
- color: selected ? c.onSurface : c.muted,
502
+ color: fg,
441
503
  // Fill+icon layout uses 12px (text-xs) — per Figma spec.
442
504
  fontSize: verticalLayout ? 12 : null,
443
505
  ),
444
506
  ),
445
507
  );
446
508
 
447
- return GestureDetector(
448
- onTap: onTap,
509
+ final Widget gesture = GestureDetector(
510
+ onTap: widget.onTap,
449
511
  behavior: HitTestBehavior.opaque,
450
512
  child: Padding(
451
513
  padding: verticalLayout
@@ -467,25 +529,42 @@ class _PrimaryTab extends StatelessWidget {
467
529
  mainAxisSize: MainAxisSize.min,
468
530
  mainAxisAlignment: MainAxisAlignment.center,
469
531
  children: [
470
- if (item.icon != null) ...[
471
- iconWidget,
532
+ if (item.icon != null) iconWidget,
533
+ if (item.icon != null && hasLabel)
472
534
  const SizedBox(width: 6),
473
- ],
474
- labelWidget,
535
+ if (hasLabel) labelWidget,
475
536
  ],
476
537
  ),
477
538
  ),
478
539
  );
540
+
541
+ final Widget hoverable = _TabHoverRegion(
542
+ enabled: !disabled,
543
+ onHover: (value) => setState(() => _hovered = value),
544
+ child: gesture,
545
+ );
546
+
547
+ // Icon-only tabs carry no text, so expose the selection state and a label
548
+ // to screen readers explicitly (labelled tabs are described by their Text).
549
+ if (iconOnly) {
550
+ return Semantics(
551
+ button: true,
552
+ selected: selected,
553
+ label: item.semanticLabel,
554
+ child: hoverable,
555
+ );
556
+ }
557
+ return hoverable;
479
558
  }
480
559
 
481
560
  @override
482
561
  Widget build(BuildContext context) {
483
562
  final Widget inner = _tabContent(context);
484
- return expand ? Expanded(child: inner) : inner;
563
+ return widget.expand ? Expanded(child: inner) : inner;
485
564
  }
486
565
  }
487
566
 
488
- class _SecondaryTab extends StatelessWidget {
567
+ class _SecondaryTab extends StatefulWidget {
489
568
  const _SecondaryTab({
490
569
  super.key,
491
570
  required this.item,
@@ -499,12 +578,27 @@ class _SecondaryTab extends StatelessWidget {
499
578
  final bool expand;
500
579
  final VoidCallback? onTap;
501
580
 
581
+ @override
582
+ State<_SecondaryTab> createState() => _SecondaryTabState();
583
+ }
584
+
585
+ class _SecondaryTabState extends State<_SecondaryTab> {
586
+ bool _hovered = false;
587
+
502
588
  Widget _tabContent(BuildContext context) {
503
589
  final KasyColors c = context.colors;
590
+ final KasyTabItem item = widget.item;
591
+ final bool selected = widget.selected;
504
592
  final bool disabled = !item.enabled;
593
+ final bool hasLabel = item.label.isNotEmpty;
594
+ final bool iconOnly = item.icon != null && !hasLabel;
595
+
596
+ // On web/desktop, hovering an inactive tab lifts its foreground toward the
597
+ // selected color so it reads as interactive (mobile keeps the flat look).
598
+ final Color fg = (selected || _hovered) ? c.onSurface : c.muted;
505
599
 
506
- return GestureDetector(
507
- onTap: onTap,
600
+ final Widget gesture = GestureDetector(
601
+ onTap: widget.onTap,
508
602
  behavior: HitTestBehavior.opaque,
509
603
  child: Padding(
510
604
  // top:4 bottom:6 horizontal:12 — per Figma spec.
@@ -525,30 +619,49 @@ class _SecondaryTab extends StatelessWidget {
525
619
  child: Icon(
526
620
  item.icon,
527
621
  size: 16,
528
- color: selected ? c.onSurface : c.muted,
622
+ color: fg,
529
623
  ),
530
624
  ),
531
- const SizedBox(width: 6),
625
+ if (hasLabel) const SizedBox(width: 6),
532
626
  ],
533
- Opacity(
534
- opacity: disabled ? 0.4 : 1.0,
535
- child: Text(
536
- item.label,
537
- // Use labelLarge as defined in the Kasy theme (14px/w600).
538
- style: context.textTheme.labelLarge?.copyWith(
539
- color: selected ? c.onSurface : c.muted,
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(
634
+ color: fg,
635
+ ),
540
636
  ),
541
637
  ),
542
- ),
543
638
  ],
544
639
  ),
545
640
  ),
546
641
  );
642
+
643
+ final Widget hoverable = _TabHoverRegion(
644
+ enabled: !disabled,
645
+ onHover: (value) => setState(() => _hovered = value),
646
+ child: gesture,
647
+ );
648
+
649
+ // Icon-only tabs carry no text, so expose the selection state and a label
650
+ // to screen readers explicitly (labelled tabs are described by their Text).
651
+ if (iconOnly) {
652
+ return Semantics(
653
+ button: true,
654
+ selected: selected,
655
+ label: item.semanticLabel,
656
+ child: hoverable,
657
+ );
658
+ }
659
+ return hoverable;
547
660
  }
548
661
 
549
662
  @override
550
663
  Widget build(BuildContext context) {
551
664
  final Widget inner = _tabContent(context);
552
- return expand ? Expanded(child: inner) : inner;
665
+ return widget.expand ? Expanded(child: inner) : inner;
553
666
  }
554
667
  }
@@ -241,9 +241,20 @@ class _KasyTextFieldState extends State<KasyTextField> {
241
241
  // (No more web-specific padding — the field now uses the same vertical
242
242
  // padding on every platform so primary/web TextFields render at the same
243
243
  // height as mobile and as the KasyDatePicker trigger.)
244
- const double disabledTextOpacity = 0.56;
245
- const double disabledLabelOpacity = 0.46;
246
- const double disabledDescriptionOpacity = 0.34;
244
+
245
+ // ── Disabled state: opaque "softened" colors, NOT transparency. ────────
246
+ // Kit-wide rule (see KasyButton): keep the original hue but render it
247
+ // weaker by alpha-blending toward the surface — never use raw opacity,
248
+ // which would leak whatever sits behind the widget. [dimDisabled] takes
249
+ // any base color and returns an opaque, softer version of it (or the
250
+ // color itself when the field is enabled). The [alpha] parameter
251
+ // controls how much of the original color is kept: higher = stronger
252
+ // (closer to the original), lower = softer (closer to the surface).
253
+ final Color blendSurface = context.colors.surface;
254
+ Color dimDisabled(Color base, {double alpha = 0.46}) {
255
+ if (!isDisabled) return base;
256
+ return Color.alphaBlend(base.withValues(alpha: alpha), blendSurface);
257
+ }
247
258
  final bool isPassword =
248
259
  widget.contentType == KasyTextFieldContentType.password;
249
260
  final bool isEmail = widget.contentType == KasyTextFieldContentType.email;
@@ -289,16 +300,18 @@ class _KasyTextFieldState extends State<KasyTextField> {
289
300
  final TextStyle labelStyle = labelBaseStyle.copyWith(
290
301
  color: hasInvalidState
291
302
  ? context.colors.error
292
- : (labelBaseStyle.color ?? context.colors.fieldLabel).withValues(
293
- alpha: isDisabled ? disabledLabelOpacity : 1,
303
+ : dimDisabled(
304
+ labelBaseStyle.color ?? context.colors.fieldLabel,
305
+ alpha: 0.55,
294
306
  ),
295
307
  );
296
308
  final TextStyle descriptionStyle =
297
309
  (hasInvalidState ? errorBaseStyle : helperBaseStyle).copyWith(
298
310
  color: hasInvalidState
299
311
  ? context.colors.error
300
- : (helperBaseStyle.color ?? context.colors.muted).withValues(
301
- alpha: isDisabled ? disabledDescriptionOpacity : 1,
312
+ : dimDisabled(
313
+ helperBaseStyle.color ?? context.colors.muted,
314
+ alpha: 0.45,
302
315
  ),
303
316
  );
304
317
  final BorderRadius fieldRadius = BorderRadius.circular(KasyRadius.md);
@@ -372,19 +385,17 @@ class _KasyTextFieldState extends State<KasyTextField> {
372
385
  ? surfaceColor.withValues(alpha: context.isDark ? 0.9 : 0.94)
373
386
  : surfaceColor;
374
387
  // Affix icon tint matches the kit's helper-icon tone (0.62) when the
375
- // field is enabled, and fades down with the same disabled curve used by
376
- // the rest of the field so prefix/suffix don't stay "alive" while
377
- // everything else dims.
378
- final double affixAlpha = isDisabled
379
- ? 0.62 * disabledTextOpacity
380
- : 0.62;
388
+ // field is enabled. When disabled, dimDisabled blends that base color
389
+ // toward the surface the icon stays opaque, just less saturated.
390
+ final Color affixBase = context.colors.onSurface.withValues(alpha: 0.62);
391
+ final Color affixColor = dimDisabled(affixBase);
381
392
  final Widget? resolvedPrefix = widget.prefix == null
382
393
  ? null
383
394
  : Center(
384
395
  child: IconTheme.merge(
385
396
  data: IconThemeData(
386
397
  size: KasyTextField.iconGlyphSize,
387
- color: context.colors.onSurface.withValues(alpha: affixAlpha),
398
+ color: affixColor,
388
399
  ),
389
400
  child: widget.prefix!,
390
401
  ),
@@ -395,7 +406,7 @@ class _KasyTextFieldState extends State<KasyTextField> {
395
406
  child: IconTheme.merge(
396
407
  data: IconThemeData(
397
408
  size: KasyTextField.iconGlyphSize,
398
- color: context.colors.onSurface.withValues(alpha: affixAlpha),
409
+ color: affixColor,
399
410
  ),
400
411
  child: widget.suffix!,
401
412
  ),
@@ -419,24 +430,15 @@ class _KasyTextFieldState extends State<KasyTextField> {
419
430
  child: Icon(
420
431
  _passwordVisible ? KasyIcons.eyeOff : KasyIcons.eye,
421
432
  size: KasyTextField.iconGlyphSize,
422
- color: context.colors.onSurface.withValues(
423
- alpha: widget.enabled ? 0.62 : disabledTextOpacity,
424
- ),
433
+ color: affixColor,
425
434
  ),
426
435
  ),
427
436
  ),
428
437
  ),
429
438
  )
430
439
  : null);
431
- // Resolved text color for the value rendered inside the field. We bake
432
- // the disabled fade directly into the color (rather than relying on the
433
- // Material TextField's own disabled handling) so the disabled state is
434
- // visually identical across native, web and DatePicker contexts. Without
435
- // this explicit color, Material would render the value at full opacity
436
- // because [style] takes precedence over the framework's disabled fade.
437
- final Color fieldTextColor = isDisabled
438
- ? context.colors.onSurface.withValues(alpha: disabledTextOpacity)
439
- : context.colors.onSurface;
440
+
441
+ final Color fieldTextColor = dimDisabled(context.colors.onSurface);
440
442
  final TextStyle fieldTextStyle =
441
443
  context.textTheme.bodyLarge?.copyWith(
442
444
  color: fieldTextColor,
@@ -502,11 +504,11 @@ class _KasyTextFieldState extends State<KasyTextField> {
502
504
  ),
503
505
  ),
504
506
  hintStyle: hintBaseStyle.copyWith(
505
- // Match the disabled description fade so the placeholder dims along
506
- // with the rest of the field (the previous 0.42 read as "kind of
507
- // greyed out" rather than disabled).
508
- color: (hintBaseStyle.color ?? context.colors.muted).withValues(
509
- alpha: isDisabled ? disabledDescriptionOpacity : 1,
507
+ // Placeholder dims along with the field via the kit-wide softened
508
+ // color rule (opaque blend toward the surface, not raw opacity).
509
+ color: dimDisabled(
510
+ hintBaseStyle.color ?? context.colors.muted,
511
+ alpha: 0.55,
510
512
  ),
511
513
  ),
512
514
  );
@@ -618,8 +620,9 @@ class _KasyTextFieldState extends State<KasyTextField> {
618
620
  Text(
619
621
  '${_effectiveController.text.length}/${widget.maxLength}',
620
622
  style: context.textTheme.bodySmall?.copyWith(
621
- color: context.colors.onSurface.withValues(
622
- alpha: isDisabled ? disabledDescriptionOpacity : 0.54,
623
+ color: dimDisabled(
624
+ context.colors.onSurface.withValues(alpha: 0.54),
625
+ alpha: 0.55,
623
626
  ),
624
627
  fontWeight: FontWeight.w500,
625
628
  ),