kasy-cli 1.37.0 → 1.38.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 (53) hide show
  1. package/lib/scaffold/CHANGELOG.json +9 -0
  2. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  3. package/lib/scaffold/backends/patch-base-hashes.json +4 -4
  4. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  5. package/package.json +1 -1
  6. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  7. package/templates/firebase/AGENTS.md +20 -10
  8. package/templates/firebase/DESIGN_SYSTEM.md +13 -0
  9. package/templates/firebase/README.en.md +1 -1
  10. package/templates/firebase/README.es.md +1 -1
  11. package/templates/firebase/README.md +1 -1
  12. package/templates/firebase/lib/components/kasy_app_bar.dart +57 -16
  13. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  14. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  15. package/templates/firebase/lib/components/kasy_sidebar.dart +397 -28
  16. package/templates/firebase/lib/components/kasy_web_header.dart +11 -3
  17. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +2 -213
  18. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  19. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +12 -4
  20. package/templates/firebase/lib/core/chrome/web_header_scope.dart +20 -0
  21. package/templates/firebase/lib/core/data/api/user_api.dart +4 -0
  22. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  23. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  24. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  25. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  26. package/templates/firebase/lib/core/states/user_state_notifier.dart +41 -0
  27. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  28. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  29. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  30. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  31. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  32. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +498 -466
  33. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  34. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -7
  35. package/templates/firebase/lib/features/home/home_components_page.dart +16 -6
  36. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +7 -0
  37. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +23 -13
  38. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -0
  39. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  40. package/templates/firebase/lib/features/settings/settings_page.dart +51 -38
  41. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +549 -376
  42. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  43. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +266 -141
  44. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +23 -30
  45. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +30 -49
  46. package/templates/firebase/lib/i18n/en.i18n.json +23 -9
  47. package/templates/firebase/lib/i18n/es.i18n.json +23 -9
  48. package/templates/firebase/lib/i18n/pt.i18n.json +23 -9
  49. package/templates/firebase/lib/router.dart +43 -25
  50. package/templates/firebase/pubspec.yaml +1 -1
  51. package/templates/firebase/test/admin_shell_chrome_test.dart +104 -0
  52. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  53. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -1,11 +1,14 @@
1
1
  import 'dart:async';
2
+ import 'dart:ui' show ImageFilter;
2
3
 
3
4
  import 'package:flutter/foundation.dart';
4
5
  import 'package:flutter/material.dart';
5
6
  import 'package:flutter/scheduler.dart';
6
7
  import 'package:flutter/services.dart';
8
+ import 'package:google_fonts/google_fonts.dart';
7
9
  import 'package:kasy_kit/core/dev_inspector/dev_inspector_info.dart';
8
10
  import 'package:kasy_kit/core/dev_inspector/dev_inspector_service.dart';
11
+ import 'package:kasy_kit/core/icons/kasy_icons.dart';
9
12
  import 'package:kasy_kit/core/theme/providers/theme_provider.dart';
10
13
  import 'package:kasy_kit/i18n/translations.g.dart';
11
14
  import 'package:shared_preferences/shared_preferences.dart';
@@ -28,13 +31,15 @@ final GlobalKey<ScaffoldMessengerState> devInspectorRootScaffoldMessengerKey =
28
31
  /// users who already had the toggle on before the FAB was removed.
29
32
  const String devInspectorEnabledPrefKey = 'dev_inspector_fab_enabled';
30
33
 
31
- final ValueNotifier<bool> devInspectorEnabledNotifier =
32
- ValueNotifier<bool>(false);
34
+ final ValueNotifier<bool> devInspectorEnabledNotifier = ValueNotifier<bool>(
35
+ false,
36
+ );
33
37
 
34
38
  /// Set to true to trigger a copy of the currently selected widget.
35
39
  /// [DevInspector] handles the copy + feedback and resets this to false.
36
- final ValueNotifier<bool> devInspectorCopyTriggerNotifier =
37
- ValueNotifier<bool>(false);
40
+ final ValueNotifier<bool> devInspectorCopyTriggerNotifier = ValueNotifier<bool>(
41
+ false,
42
+ );
38
43
 
39
44
  /// Set to true to clear the current selection WITHOUT deactivating the
40
45
  /// inspector. The Web Device Preview toggle fires this when entering/leaving
@@ -46,8 +51,9 @@ final ValueNotifier<bool> devInspectorClearSelectionTriggerNotifier =
46
51
  /// Runtime active state of the inspector. Mirrors [devInspectorEnabledNotifier]
47
52
  /// — the Web Device Preview pill, the admin toggle and the Esc shortcut all
48
53
  /// flip the persisted notifier, and this one follows.
49
- final ValueNotifier<bool> devInspectorActiveNotifier =
50
- ValueNotifier<bool>(false);
54
+ final ValueNotifier<bool> devInspectorActiveNotifier = ValueNotifier<bool>(
55
+ false,
56
+ );
51
57
 
52
58
  /// Set to true to hide the in-app status pill that the [DevInspector] shows
53
59
  /// while active (e.g. when the WebDevicePreview chrome is already displaying
@@ -55,6 +61,17 @@ final ValueNotifier<bool> devInspectorActiveNotifier =
55
61
  final ValueNotifier<bool> devInspectorSuppressStatusPillNotifier =
56
62
  ValueNotifier<bool>(false);
57
63
 
64
+ /// Confirmation message the [DevInspector] wants surfaced. When the
65
+ /// WebDevicePreview chrome is present it OWNS the toast surface (so the
66
+ /// inspector's "copied" pill lands in the exact same spot and size as the
67
+ /// chrome's own toasts, e.g. "Image copied"). The chrome listens here, shows
68
+ /// the toast, and resets this to null. When no chrome is present the inspector
69
+ /// renders its own in-app toast instead.
70
+ final ValueNotifier<({String message, bool isError})?>
71
+ devInspectorToastNotifier = ValueNotifier<({String message, bool isError})?>(
72
+ null,
73
+ );
74
+
58
75
  /// Rect of the currently selected widget expressed in **root view coordinates**
59
76
  /// (the browser window / native window). External surfaces — e.g. the Web
60
77
  /// Device Preview chrome which renders OUTSIDE the device frame — listen to
@@ -94,11 +111,13 @@ class DevInspector extends StatefulWidget {
94
111
  class _DevInspectorState extends State<DevInspector>
95
112
  with TickerProviderStateMixin {
96
113
  static const Duration _copyFeedbackVisible = Duration(milliseconds: 2200);
97
- static const double _copyFeedbackRightInset = 16;
98
114
  static const int _historyCapacity = 20;
99
115
 
100
116
  bool _active = false;
101
117
  bool _copyBusy = false;
118
+ // The X only hides the info card; the highlighted selection stays. Tapping a
119
+ // widget again (or selecting another) brings the card back.
120
+ bool _infoCardHidden = false;
102
121
 
103
122
  Timer? _copyFeedbackTimer;
104
123
  String? _copyFeedbackText;
@@ -107,6 +126,9 @@ class _DevInspectorState extends State<DevInspector>
107
126
  DevInspectorInfo? _selectedInfo;
108
127
  RenderObject? _selectedRender;
109
128
  Rect? _highlightRect;
129
+ // Live highlight of whatever the cursor is over (hover-to-inspect). Distinct
130
+ // from the locked selection [_highlightRect] — drawn softer, no history/copy.
131
+ Rect? _hoverRect;
110
132
  Ticker? _highlightTicker;
111
133
  late final AnimationController _transitionCtrl;
112
134
  Rect? _transitionFromRect;
@@ -134,8 +156,9 @@ class _DevInspectorState extends State<DevInspector>
134
156
  devInspectorActiveNotifier.addListener(_handleActiveChanged);
135
157
  devInspectorEnabledNotifier.addListener(_handleEnabledChanged);
136
158
  devInspectorCopyTriggerNotifier.addListener(_onCopyTriggered);
137
- devInspectorClearSelectionTriggerNotifier
138
- .addListener(_onClearSelectionTriggered);
159
+ devInspectorClearSelectionTriggerNotifier.addListener(
160
+ _onClearSelectionTriggered,
161
+ );
139
162
  HardwareKeyboard.instance.addHandler(_handleKeyEvent);
140
163
  unawaited(_bootstrapEnabledPreference());
141
164
  }
@@ -148,8 +171,9 @@ class _DevInspectorState extends State<DevInspector>
148
171
  HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
149
172
  devInspectorEnabledNotifier.removeListener(_handleEnabledChanged);
150
173
  devInspectorCopyTriggerNotifier.removeListener(_onCopyTriggered);
151
- devInspectorClearSelectionTriggerNotifier
152
- .removeListener(_onClearSelectionTriggered);
174
+ devInspectorClearSelectionTriggerNotifier.removeListener(
175
+ _onClearSelectionTriggered,
176
+ );
153
177
  devInspectorActiveNotifier.removeListener(_handleActiveChanged);
154
178
  if (devInspectorActiveNotifier.value) {
155
179
  devInspectorActiveNotifier.value = false;
@@ -195,7 +219,8 @@ class _DevInspectorState extends State<DevInspector>
195
219
 
196
220
  if (!_active) return false;
197
221
 
198
- final bool hasModifier = kb.isMetaPressed ||
222
+ final bool hasModifier =
223
+ kb.isMetaPressed ||
199
224
  kb.isControlPressed ||
200
225
  kb.isAltPressed ||
201
226
  kb.isShiftPressed;
@@ -347,6 +372,8 @@ class _DevInspectorState extends State<DevInspector>
347
372
  _selectedInfo = null;
348
373
  _selectedRender = null;
349
374
  _highlightRect = null;
375
+ _hoverRect = null;
376
+ _infoCardHidden = false;
350
377
  _transitionFromRect = null;
351
378
  _transitionToRect = null;
352
379
  _transitionFromGlobalRect = null;
@@ -371,8 +398,27 @@ class _DevInspectorState extends State<DevInspector>
371
398
  _historyCursor = _history.length - 1;
372
399
  }
373
400
 
401
+ /// Live hover preview: highlight whatever widget is under the cursor without
402
+ /// selecting it. Cheap rect-diff guard avoids rebuilding when nothing moved.
403
+ void _onInspectorHover(Offset globalPosition) {
404
+ if (!_active) return;
405
+ final RenderObject? content = _contentKey.currentContext
406
+ ?.findRenderObject();
407
+ if (content is! RenderBox) return;
408
+ final picked = DevInspectorService.pickAtInBox(content, globalPosition);
409
+ final Rect? rect = picked == null
410
+ ? null
411
+ : _rectInOverlaySpace(picked.renderObject);
412
+ if (rect != _hoverRect) setState(() => _hoverRect = rect);
413
+ }
414
+
415
+ void _clearHover() {
416
+ if (_hoverRect != null) setState(() => _hoverRect = null);
417
+ }
418
+
374
419
  void _onInspectorTap(Offset globalPosition) {
375
- final RenderObject? content = _contentKey.currentContext?.findRenderObject();
420
+ final RenderObject? content = _contentKey.currentContext
421
+ ?.findRenderObject();
376
422
  if (content is! RenderBox) {
377
423
  HapticFeedback.heavyImpact();
378
424
  return;
@@ -400,6 +446,8 @@ class _DevInspectorState extends State<DevInspector>
400
446
  setState(() {
401
447
  _selectedInfo = picked!.info;
402
448
  _selectedRender = picked.renderObject;
449
+ // A fresh tap always brings the card back (even on the same element).
450
+ _infoCardHidden = false;
403
451
  });
404
452
 
405
453
  if (!_navigatingHistory) {
@@ -457,8 +505,8 @@ class _DevInspectorState extends State<DevInspector>
457
505
  /// transforms applied above the overlay (e.g. WebDevicePreview's scale).
458
506
  Rect? _rectInOverlaySpace(RenderObject target) {
459
507
  if (!target.attached) return null;
460
- final RenderObject? overlay =
461
- _overlayKey.currentContext?.findRenderObject();
508
+ final RenderObject? overlay = _overlayKey.currentContext
509
+ ?.findRenderObject();
462
510
  if (overlay == null || !overlay.attached) return null;
463
511
  try {
464
512
  final Matrix4 transform = target.getTransformTo(overlay);
@@ -516,6 +564,14 @@ class _DevInspectorState extends State<DevInspector>
516
564
  }
517
565
 
518
566
  void _showCopyFeedback(String message, {required bool isError}) {
567
+ // When the WebDevicePreview chrome is up it owns the toast surface — hand
568
+ // the message off so the confirmation appears in the exact same place and
569
+ // size as the chrome's other toasts (e.g. "Image copied"). No local toast
570
+ // in that case, to avoid showing two.
571
+ if (devInspectorSuppressStatusPillNotifier.value) {
572
+ devInspectorToastNotifier.value = (message: message, isError: isError);
573
+ return;
574
+ }
519
575
  _copyFeedbackTimer?.cancel();
520
576
  setState(() {
521
577
  _copyFeedbackText = message;
@@ -559,6 +615,43 @@ class _DevInspectorState extends State<DevInspector>
559
615
  }
560
616
  }
561
617
 
618
+ /// Positions the glass info card next to the selected element, always clamped
619
+ /// inside the viewport — so it stays visible even for full-screen selections
620
+ /// (where there's no room above or below the element).
621
+ Widget _buildInfoCard(Size size, EdgeInsets padding, bool dark) {
622
+ final Rect rect = _highlightRect!;
623
+ final DevInspectorInfo info = _selectedInfo!;
624
+ const double panelW = 250;
625
+ const double gap = 10;
626
+ const double estHeight = 130;
627
+ final double minTop = padding.top + 8;
628
+ final double maxTop = size.height - padding.bottom - estHeight - 8;
629
+
630
+ double left = rect.left;
631
+ if (left + panelW > size.width - 8) left = size.width - 8 - panelW;
632
+ if (left < 8) left = 8;
633
+
634
+ // Prefer below the element; fall back to above; then clamp on-screen.
635
+ double top = rect.bottom + gap;
636
+ if (top > maxTop) top = rect.top - gap - estHeight;
637
+ top = top.clamp(minTop, maxTop < minTop ? minTop : maxTop);
638
+
639
+ return Positioned(
640
+ left: left,
641
+ top: top,
642
+ child: _InspectorInfoCard(
643
+ info: info,
644
+ dark: dark,
645
+ maxWidth: panelW,
646
+ onCopy: () {
647
+ unawaited(_copySelection());
648
+ setState(() => _infoCardHidden = true);
649
+ },
650
+ onClose: () => setState(() => _infoCardHidden = true),
651
+ ),
652
+ );
653
+ }
654
+
562
655
  (Size, EdgeInsets) _sizeAndPadding(BuildContext context) {
563
656
  final MediaQueryData? mq = MediaQuery.maybeOf(context);
564
657
  if (mq != null) return (mq.size, mq.padding);
@@ -592,6 +685,7 @@ class _DevInspectorState extends State<DevInspector>
592
685
  ? appTheme.dark
593
686
  : appTheme.light,
594
687
  };
688
+ final bool dark = shellTheme.brightness == Brightness.dark;
595
689
 
596
690
  return Directionality(
597
691
  textDirection: _platformTextDirection(),
@@ -602,69 +696,72 @@ class _DevInspectorState extends State<DevInspector>
602
696
  return Stack(
603
697
  clipBehavior: Clip.none,
604
698
  children: <Widget>[
605
- KeyedSubtree(key: _contentKey, child: widget.child),
699
+ // While inspecting, the app must not react to clicks — only the
700
+ // inspector selects/highlights. AbsorbPointer swallows pointer
701
+ // events for the app subtree. Picking still works: the inspector
702
+ // calls hitTest directly on the content render box (via
703
+ // _contentKey), which is a CHILD of this AbsorbPointer, so the
704
+ // direct call bypasses the absorption.
705
+ AbsorbPointer(
706
+ absorbing: _active,
707
+ child: KeyedSubtree(key: _contentKey, child: widget.child),
708
+ ),
606
709
  if (_active)
607
710
  Positioned.fill(
608
- child: Listener(
609
- behavior: HitTestBehavior.opaque,
610
- onPointerDown: (PointerDownEvent ev) =>
611
- _onInspectorTap(ev.position),
612
- child: IgnorePointer(
613
- child: CustomPaint(
614
- key: _overlayKey,
615
- painter: _HighlightPainter(rect: _highlightRect),
616
- size: Size.infinite,
711
+ child: MouseRegion(
712
+ opaque: false,
713
+ cursor: SystemMouseCursors.precise,
714
+ onHover: (PointerHoverEvent ev) =>
715
+ _onInspectorHover(ev.position),
716
+ onExit: (_) => _clearHover(),
717
+ child: Listener(
718
+ behavior: HitTestBehavior.opaque,
719
+ onPointerDown: (PointerDownEvent ev) =>
720
+ _onInspectorTap(ev.position),
721
+ child: IgnorePointer(
722
+ child: CustomPaint(
723
+ key: _overlayKey,
724
+ painter: _HighlightPainter(
725
+ rect: _highlightRect,
726
+ hoverRect: _hoverRect,
727
+ ),
728
+ size: Size.infinite,
729
+ ),
617
730
  ),
618
731
  ),
619
732
  ),
620
733
  ),
734
+ // Glass info card anchored to the selected element (click).
735
+ if (_active &&
736
+ !_infoCardHidden &&
737
+ _selectedInfo != null &&
738
+ _highlightRect != null)
739
+ _buildInfoCard(size, padding, dark),
621
740
  if (_active)
622
741
  Positioned(
623
742
  bottom: padding.bottom + 12,
624
743
  right: 12,
625
- child: const IgnorePointer(
626
- child: _InspectorStatusPill(),
627
- ),
744
+ child: const IgnorePointer(child: _InspectorStatusPill()),
628
745
  ),
629
- if (_copyFeedbackText != null)
630
- Positioned(
631
- left: 16,
632
- right: 16 + _copyFeedbackRightInset,
633
- bottom: padding.bottom + 12,
634
- child: IgnorePointer(
635
- child: Builder(
636
- builder: (BuildContext ctx) {
637
- final ThemeData theme = Theme.of(ctx);
638
- final ColorScheme scheme = theme.colorScheme;
639
- final Color bg = _copyFeedbackIsError
640
- ? scheme.error
641
- : scheme.inverseSurface;
642
- final Color fg = _copyFeedbackIsError
643
- ? scheme.onError
644
- : scheme.onInverseSurface;
645
- return Material(
646
- elevation: 12,
647
- shadowColor: Colors.black45,
648
- borderRadius: BorderRadius.circular(12),
649
- color: bg,
650
- child: Padding(
651
- padding: const EdgeInsets.symmetric(
652
- horizontal: 16,
653
- vertical: 12,
654
- ),
655
- child: Text(
656
- _copyFeedbackText!,
657
- style: theme.textTheme.bodyMedium?.copyWith(
658
- color: fg,
659
- fontWeight: FontWeight.w500,
660
- ),
746
+ Positioned(
747
+ left: 0,
748
+ right: 0,
749
+ bottom: padding.bottom + 24,
750
+ child: IgnorePointer(
751
+ child: Center(
752
+ child: AnimatedSwitcher(
753
+ duration: const Duration(milliseconds: 180),
754
+ child: _copyFeedbackText == null
755
+ ? const SizedBox.shrink()
756
+ : _InspectorToast(
757
+ key: ValueKey<String>(_copyFeedbackText!),
758
+ message: _copyFeedbackText!,
759
+ isError: _copyFeedbackIsError,
661
760
  ),
662
- ),
663
- );
664
- },
665
761
  ),
666
762
  ),
667
763
  ),
764
+ ),
668
765
  ],
669
766
  );
670
767
  },
@@ -674,13 +771,323 @@ class _DevInspectorState extends State<DevInspector>
674
771
  }
675
772
  }
676
773
 
774
+ /// Frosted-glass info card shown next to the selected element. Adapts to light
775
+ /// and dark themes; offers Copy ID / Copy JSON / Copy for AI.
776
+ class _InspectorInfoCard extends StatelessWidget {
777
+ const _InspectorInfoCard({
778
+ required this.info,
779
+ required this.dark,
780
+ required this.maxWidth,
781
+ required this.onCopy,
782
+ required this.onClose,
783
+ });
784
+
785
+ final DevInspectorInfo info;
786
+ final bool dark;
787
+ final double maxWidth;
788
+ final VoidCallback onCopy;
789
+ final VoidCallback onClose;
790
+
791
+ @override
792
+ Widget build(BuildContext context) {
793
+ // Real frosted glass. Dark: a light tint that darkens downward. Light: a
794
+ // frosted WHITE — higher opacity so it reads as crisp glass instead of a
795
+ // washed-out tint over the light content behind it.
796
+ final Color glassTop = dark
797
+ ? Colors.white.withValues(alpha: 0.10)
798
+ : Colors.white.withValues(alpha: 0.82);
799
+ final Color glassBottom = dark
800
+ ? Colors.black.withValues(alpha: 0.30)
801
+ : Colors.white.withValues(alpha: 0.62);
802
+ final Color border = (dark ? Colors.white : Colors.black).withValues(
803
+ alpha: dark ? 0.14 : 0.08,
804
+ );
805
+ final Color fg = dark ? Colors.white : const Color(0xFF1C1C1E);
806
+ final Color faint = fg.withValues(alpha: 0.45);
807
+
808
+ return Material(
809
+ type: MaterialType.transparency,
810
+ child: ConstrainedBox(
811
+ constraints: BoxConstraints(maxWidth: maxWidth),
812
+ child: ClipRRect(
813
+ borderRadius: BorderRadius.circular(16),
814
+ child: BackdropFilter(
815
+ filter: ImageFilter.blur(sigmaX: 26, sigmaY: 26),
816
+ child: Container(
817
+ decoration: BoxDecoration(
818
+ gradient: LinearGradient(
819
+ begin: Alignment.topLeft,
820
+ end: Alignment.bottomRight,
821
+ colors: [glassTop, glassBottom],
822
+ ),
823
+ borderRadius: BorderRadius.circular(16),
824
+ border: Border.all(color: border),
825
+ boxShadow: [
826
+ BoxShadow(
827
+ color: Colors.black.withValues(alpha: dark ? 0.45 : 0.18),
828
+ blurRadius: 28,
829
+ offset: const Offset(0, 10),
830
+ ),
831
+ ],
832
+ ),
833
+ child: Stack(
834
+ children: [
835
+ Column(
836
+ mainAxisSize: MainAxisSize.min,
837
+ crossAxisAlignment: CrossAxisAlignment.stretch,
838
+ children: [
839
+ // Header: role chip (close button floats in the corner).
840
+ Padding(
841
+ padding: const EdgeInsets.fromLTRB(12, 10, 38, 8),
842
+ child: Align(
843
+ alignment: Alignment.centerLeft,
844
+ child: Container(
845
+ padding: const EdgeInsets.symmetric(
846
+ horizontal: 8,
847
+ vertical: 3,
848
+ ),
849
+ decoration: BoxDecoration(
850
+ color: _highlightColor.withValues(alpha: 0.20),
851
+ borderRadius: BorderRadius.circular(6),
852
+ ),
853
+ child: Text(
854
+ info.widgetType,
855
+ overflow: TextOverflow.ellipsis,
856
+ style: TextStyle(
857
+ color: fg,
858
+ fontWeight: FontWeight.w700,
859
+ fontSize: 12.5,
860
+ ),
861
+ ),
862
+ ),
863
+ ),
864
+ ),
865
+ Divider(height: 1, color: border),
866
+ // Essential, non-redundant fields only: color (as hex) and
867
+ // size. Type is the chip; text/labels are skipped (already
868
+ // visible on screen); the rest lives in the copied context.
869
+ Padding(
870
+ padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
871
+ child: Column(
872
+ crossAxisAlignment: CrossAxisAlignment.start,
873
+ children: [
874
+ if (info.colorHex != null)
875
+ _field(
876
+ t.devInspector.fieldColor,
877
+ info.colorHex!,
878
+ faint,
879
+ fg,
880
+ swatch: _parseHex(info.colorHex!),
881
+ ),
882
+ _field(
883
+ t.devInspector.fieldSize,
884
+ info.sizeLabel,
885
+ faint,
886
+ fg,
887
+ ),
888
+ ],
889
+ ),
890
+ ),
891
+ Divider(height: 1, color: border),
892
+ // Single copy action — subtle, so it doesn't shout.
893
+ Padding(
894
+ padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
895
+ child: _CardAction(
896
+ label: t.devInspector.copyJson,
897
+ fg: fg,
898
+ onTap: onCopy,
899
+ ),
900
+ ),
901
+ ],
902
+ ),
903
+ // Close button pinned to the top-right corner.
904
+ Positioned(
905
+ top: 6,
906
+ right: 6,
907
+ child: _CardIconBtn(
908
+ icon: KasyIcons.close,
909
+ color: faint,
910
+ onTap: onClose,
911
+ ),
912
+ ),
913
+ ],
914
+ ),
915
+ ),
916
+ ),
917
+ ),
918
+ ),
919
+ );
920
+ }
921
+
922
+ Widget _field(
923
+ String label,
924
+ String value,
925
+ Color labelColor,
926
+ Color valueColor, {
927
+ Color? swatch,
928
+ }) {
929
+ return Padding(
930
+ padding: const EdgeInsets.only(bottom: 6),
931
+ child: Row(
932
+ crossAxisAlignment: CrossAxisAlignment.start,
933
+ children: [
934
+ SizedBox(
935
+ width: 66,
936
+ child: Text(
937
+ label,
938
+ overflow: TextOverflow.ellipsis,
939
+ style: TextStyle(
940
+ color: labelColor,
941
+ fontSize: 11,
942
+ fontWeight: FontWeight.w500,
943
+ ),
944
+ ),
945
+ ),
946
+ const SizedBox(width: 8),
947
+ if (swatch != null) ...[
948
+ Container(
949
+ width: 13,
950
+ height: 13,
951
+ margin: const EdgeInsets.only(top: 1, right: 6),
952
+ decoration: BoxDecoration(
953
+ color: swatch,
954
+ borderRadius: BorderRadius.circular(3),
955
+ border: Border.all(color: labelColor.withValues(alpha: 0.4)),
956
+ ),
957
+ ),
958
+ ],
959
+ Expanded(
960
+ child: Text(
961
+ value,
962
+ style: TextStyle(
963
+ color: valueColor,
964
+ fontSize: 12,
965
+ fontWeight: FontWeight.w500,
966
+ ),
967
+ ),
968
+ ),
969
+ ],
970
+ ),
971
+ );
972
+ }
973
+
974
+ /// `#RRGGBB` or `#AARRGGBB` → Color, for the color swatch. Null if unparseable.
975
+ Color? _parseHex(String hex) {
976
+ var h = hex.replaceAll('#', '').trim();
977
+ if (h.length == 6) h = 'FF$h';
978
+ if (h.length != 8) return null;
979
+ final v = int.tryParse(h, radix: 16);
980
+ return v == null ? null : Color(v);
981
+ }
982
+ }
983
+
984
+ class _CardIconBtn extends StatelessWidget {
985
+ const _CardIconBtn({
986
+ required this.icon,
987
+ required this.color,
988
+ required this.onTap,
989
+ });
990
+
991
+ final IconData icon;
992
+ final Color color;
993
+ final VoidCallback onTap;
994
+
995
+ @override
996
+ Widget build(BuildContext context) {
997
+ return InkResponse(
998
+ onTap: onTap,
999
+ radius: 18,
1000
+ child: Padding(
1001
+ padding: const EdgeInsets.all(6),
1002
+ child: Icon(icon, color: color, size: 16),
1003
+ ),
1004
+ );
1005
+ }
1006
+ }
1007
+
1008
+ /// Compact, subtle action chip inside the info card — a quiet neutral fill that
1009
+ /// brightens slightly on hover, so it never competes with the inspected UI.
1010
+ class _CardAction extends StatefulWidget {
1011
+ const _CardAction({
1012
+ required this.label,
1013
+ required this.fg,
1014
+ required this.onTap,
1015
+ });
1016
+
1017
+ final String label;
1018
+ final Color fg;
1019
+ final VoidCallback onTap;
1020
+
1021
+ @override
1022
+ State<_CardAction> createState() => _CardActionState();
1023
+ }
1024
+
1025
+ class _CardActionState extends State<_CardAction> {
1026
+ bool _hovered = false;
1027
+
1028
+ @override
1029
+ Widget build(BuildContext context) {
1030
+ final Color bg = widget.fg.withValues(alpha: _hovered ? 0.12 : 0.06);
1031
+ return MouseRegion(
1032
+ onEnter: (_) => setState(() => _hovered = true),
1033
+ onExit: (_) => setState(() => _hovered = false),
1034
+ cursor: SystemMouseCursors.click,
1035
+ child: GestureDetector(
1036
+ onTap: widget.onTap,
1037
+ child: AnimatedContainer(
1038
+ duration: const Duration(milliseconds: 120),
1039
+ padding: const EdgeInsets.symmetric(vertical: 9),
1040
+ alignment: Alignment.center,
1041
+ decoration: BoxDecoration(
1042
+ color: bg,
1043
+ borderRadius: BorderRadius.circular(9),
1044
+ ),
1045
+ child: Text(
1046
+ widget.label,
1047
+ style: TextStyle(
1048
+ color: widget.fg.withValues(alpha: 0.75),
1049
+ fontSize: 12,
1050
+ fontWeight: FontWeight.w500,
1051
+ ),
1052
+ ),
1053
+ ),
1054
+ ),
1055
+ );
1056
+ }
1057
+ }
1058
+
677
1059
  class _HighlightPainter extends CustomPainter {
678
- _HighlightPainter({required this.rect});
1060
+ _HighlightPainter({required this.rect, this.hoverRect});
679
1061
 
680
1062
  final Rect? rect;
1063
+ final Rect? hoverRect;
681
1064
 
682
1065
  @override
683
1066
  void paint(Canvas canvas, Size size) {
1067
+ // Hover preview first (under the selection): soft translucent fill + thin
1068
+ // stroke. Skipped when it coincides with the locked selection.
1069
+ final Rect? hover = hoverRect;
1070
+ if (hover != null && !hover.isEmpty && hover != rect) {
1071
+ final RRect hoverRRect = RRect.fromRectAndRadius(
1072
+ hover,
1073
+ const Radius.circular(_highlightCornerRadius),
1074
+ );
1075
+ canvas.drawRRect(
1076
+ hoverRRect,
1077
+ Paint()
1078
+ ..color = _highlightColor.withValues(alpha: 0.12)
1079
+ ..style = PaintingStyle.fill,
1080
+ );
1081
+ canvas.drawRRect(
1082
+ hoverRRect,
1083
+ Paint()
1084
+ ..color = _highlightColor.withValues(alpha: 0.6)
1085
+ ..style = PaintingStyle.stroke
1086
+ ..strokeWidth = 1.0,
1087
+ );
1088
+ }
1089
+
1090
+ // Locked selection: solid stroke.
684
1091
  final Rect? r = rect;
685
1092
  if (r == null || r.isEmpty) return;
686
1093
  final Paint paint = Paint()
@@ -697,7 +1104,7 @@ class _HighlightPainter extends CustomPainter {
697
1104
 
698
1105
  @override
699
1106
  bool shouldRepaint(_HighlightPainter oldDelegate) =>
700
- oldDelegate.rect != rect;
1107
+ oldDelegate.rect != rect || oldDelegate.hoverRect != hoverRect;
701
1108
  }
702
1109
 
703
1110
  /// Minimal status pill tucked in the bottom-right corner while the inspector
@@ -748,6 +1155,59 @@ class _InspectorStatusPill extends StatelessWidget {
748
1155
  }
749
1156
  }
750
1157
 
1158
+ /// Modern confirmation pill shown briefly after a copy. Mirrors the toast used
1159
+ /// by the WebDevicePreview chrome: dark frosted capsule, leading status icon,
1160
+ /// Inter text. Success shows a green check; errors show a red alert.
1161
+ class _InspectorToast extends StatelessWidget {
1162
+ const _InspectorToast({
1163
+ super.key,
1164
+ required this.message,
1165
+ required this.isError,
1166
+ });
1167
+
1168
+ final String message;
1169
+ final bool isError;
1170
+
1171
+ @override
1172
+ Widget build(BuildContext context) {
1173
+ return DecoratedBox(
1174
+ decoration: BoxDecoration(
1175
+ color: const Color(0xF02C2C2E),
1176
+ borderRadius: BorderRadius.circular(20),
1177
+ boxShadow: const <BoxShadow>[
1178
+ BoxShadow(
1179
+ color: Color(0x40000000),
1180
+ blurRadius: 12,
1181
+ offset: Offset(0, 3),
1182
+ ),
1183
+ ],
1184
+ ),
1185
+ child: Padding(
1186
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
1187
+ child: Row(
1188
+ mainAxisSize: MainAxisSize.min,
1189
+ children: <Widget>[
1190
+ Icon(
1191
+ isError ? KasyIcons.error : KasyIcons.checkCircle,
1192
+ color: isError ? const Color(0xFFFF453A) : const Color(0xFF34C759),
1193
+ size: 15,
1194
+ ),
1195
+ const SizedBox(width: 8),
1196
+ Text(
1197
+ message,
1198
+ style: GoogleFonts.inter(
1199
+ fontSize: 12,
1200
+ fontWeight: FontWeight.w500,
1201
+ color: Colors.white,
1202
+ ),
1203
+ ),
1204
+ ],
1205
+ ),
1206
+ ),
1207
+ );
1208
+ }
1209
+ }
1210
+
751
1211
  class _PulsingDot extends StatefulWidget {
752
1212
  const _PulsingDot();
753
1213
  @override