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
@@ -6,29 +6,29 @@ import 'package:flutter/scheduler.dart';
6
6
  import 'package:flutter/services.dart';
7
7
  import 'package:kasy_kit/core/dev_inspector/dev_inspector_info.dart';
8
8
  import 'package:kasy_kit/core/dev_inspector/dev_inspector_service.dart';
9
- import 'package:kasy_kit/core/icons/kasy_icons.dart';
10
- import 'package:kasy_kit/core/theme/colors.dart';
11
- import 'package:kasy_kit/core/theme/extensions/theme_extension.dart';
12
9
  import 'package:kasy_kit/core/theme/providers/theme_provider.dart';
13
10
  import 'package:kasy_kit/i18n/translations.g.dart';
14
11
  import 'package:shared_preferences/shared_preferences.dart';
15
12
 
16
- const Color _highlightColor = Color(0xFFD2F51E);
13
+ /// Same lime used by the Kasy CLI brand color (Tailwind lime-500). Balanced
14
+ /// for visibility on both light and dark surfaces.
15
+ const Color _highlightColor = Color(0xFF84CC16);
17
16
  const double _highlightStrokeWidth = 2.0;
18
17
  const double _highlightCornerRadius = 4.0;
19
18
 
20
19
  final GlobalKey<ScaffoldMessengerState> devInspectorRootScaffoldMessengerKey =
21
20
  GlobalKey<ScaffoldMessengerState>(debugLabel: 'devInspectorRoot');
22
21
 
23
- /// Persisted flag Settings (admin, debug only) toggles this; [DevInspector] listens.
24
- const String devInspectorFabEnabledPrefKey = 'dev_inspector_fab_enabled';
22
+ /// Persisted master switch flipped from the admin settings sheet. When `true`
23
+ /// the inspector is permanently armed (`devInspectorActiveNotifier` follows it).
24
+ /// Esc/pill toggles flip this same flag, which is then persisted to prefs so
25
+ /// the next launch matches what the user left.
26
+ ///
27
+ /// Key kept as `dev_inspector_fab_enabled` for backwards compatibility with
28
+ /// users who already had the toggle on before the FAB was removed.
29
+ const String devInspectorEnabledPrefKey = 'dev_inspector_fab_enabled';
25
30
 
26
- final ValueNotifier<bool> devInspectorFabEnabledNotifier =
27
- ValueNotifier<bool>(false);
28
-
29
- /// Set to true to reveal the FAB immediately (bypasses the startup delay).
30
- /// [DevInspector] resets it back to false after consuming the signal.
31
- final ValueNotifier<bool> devInspectorRevealNowNotifier =
31
+ final ValueNotifier<bool> devInspectorEnabledNotifier =
32
32
  ValueNotifier<bool>(false);
33
33
 
34
34
  /// Set to true to trigger a copy of the currently selected widget.
@@ -36,12 +36,40 @@ final ValueNotifier<bool> devInspectorRevealNowNotifier =
36
36
  final ValueNotifier<bool> devInspectorCopyTriggerNotifier =
37
37
  ValueNotifier<bool>(false);
38
38
 
39
- /// Active state of the custom inspector. Toggled by the FAB (native) and by
40
- /// the Web Device Preview pill; observed by [DevInspector] to mount the
41
- /// tap-absorbing overlay and the highlight.
39
+ /// Runtime active state of the inspector. Mirrors [devInspectorEnabledNotifier]
40
+ /// the Web Device Preview pill, the admin toggle and the Esc shortcut all
41
+ /// flip the persisted notifier, and this one follows.
42
42
  final ValueNotifier<bool> devInspectorActiveNotifier =
43
43
  ValueNotifier<bool>(false);
44
44
 
45
+ /// Set to true to hide the in-app status pill that the [DevInspector] shows
46
+ /// while active (e.g. when the WebDevicePreview chrome is already displaying
47
+ /// its own inspector state).
48
+ final ValueNotifier<bool> devInspectorSuppressStatusPillNotifier =
49
+ ValueNotifier<bool>(false);
50
+
51
+ /// Rect of the currently selected widget expressed in **root view coordinates**
52
+ /// (the browser window / native window). External surfaces — e.g. the Web
53
+ /// Device Preview chrome which renders OUTSIDE the device frame — listen to
54
+ /// this and draw the highlight above the frame so it stays visible even when
55
+ /// the selected widget hugs the edges of the simulated device.
56
+ final ValueNotifier<Rect?> devInspectorHighlightGlobalRect =
57
+ ValueNotifier<Rect?>(null);
58
+
59
+ const Color devInspectorHighlightColor = _highlightColor;
60
+ const double devInspectorHighlightStrokeWidth = _highlightStrokeWidth;
61
+ const double devInspectorHighlightCornerRadius = _highlightCornerRadius;
62
+
63
+ /// Keyboard shortcut for toggling the inspector, formatted for the current
64
+ /// platform. Key names are kept in English regardless of the app locale —
65
+ /// "Command", "Ctrl" and "Shift" are universal keyboard conventions.
66
+ String devInspectorShortcutLabel() {
67
+ final String mod = defaultTargetPlatform == TargetPlatform.macOS
68
+ ? 'Command'
69
+ : 'Ctrl';
70
+ return '$mod + Shift + P';
71
+ }
72
+
45
73
  class DevInspector extends StatefulWidget {
46
74
  const DevInspector({super.key, required this.child});
47
75
 
@@ -57,56 +85,59 @@ class DevInspector extends StatefulWidget {
57
85
  }
58
86
 
59
87
  class _DevInspectorState extends State<DevInspector>
60
- with SingleTickerProviderStateMixin {
61
- static const Duration _fabRevealDelay = Duration(seconds: 2);
62
- static const Duration _fabDeemphasizeDelay = Duration(seconds: 2);
63
- static const Duration _fabOpacityAnimation = Duration(milliseconds: 700);
64
- static const double _fabOpacityIdle = 0.75;
65
- static const double _fabOpacityInspectorOn = 1.0;
88
+ with TickerProviderStateMixin {
66
89
  static const Duration _copyFeedbackVisible = Duration(milliseconds: 2200);
67
- /// Horizontal inset from the right so the banner does not sit under the FAB.
68
- static const double _copyFeedbackRightInset = 68;
90
+ static const double _copyFeedbackRightInset = 16;
91
+ static const int _historyCapacity = 20;
69
92
 
70
93
  bool _active = false;
71
94
  bool _copyBusy = false;
72
95
 
73
- double? _fabDx;
74
- double? _fabDy;
75
- bool _dragging = false;
76
-
77
- Timer? _revealTimer;
78
- Timer? _fabDeemphasizeTimer;
79
96
  Timer? _copyFeedbackTimer;
80
97
  String? _copyFeedbackText;
81
98
  bool _copyFeedbackIsError = false;
82
- bool _fabLayerMounted = false;
83
- bool _fabFadeIn = false;
84
- bool _fabEmphasized = false;
85
99
 
86
100
  DevInspectorInfo? _selectedInfo;
87
101
  RenderObject? _selectedRender;
88
102
  Rect? _highlightRect;
89
103
  Ticker? _highlightTicker;
104
+ late final AnimationController _transitionCtrl;
105
+ Rect? _transitionFromRect;
106
+ Rect? _transitionToRect;
107
+ Rect? _transitionFromGlobalRect;
108
+ Rect? _transitionToGlobalRect;
90
109
  final GlobalKey _overlayKey = GlobalKey(debugLabel: 'devInspectorOverlay');
110
+ final GlobalKey _contentKey = GlobalKey(debugLabel: 'devInspectorContent');
111
+
112
+ /// Past selections, oldest → newest. [_historyCursor] points at the current
113
+ /// position; ← and → keys walk this list. Cleared when the inspector is
114
+ /// disabled.
115
+ final List<({DevInspectorInfo info, RenderObject renderObject})> _history =
116
+ [];
117
+ int _historyCursor = -1;
118
+ bool _navigatingHistory = false;
91
119
 
92
120
  @override
93
121
  void initState() {
94
122
  super.initState();
123
+ _transitionCtrl = AnimationController(
124
+ vsync: this,
125
+ duration: const Duration(milliseconds: 140),
126
+ )..addListener(_handleTransitionTick);
95
127
  devInspectorActiveNotifier.addListener(_handleActiveChanged);
96
- devInspectorFabEnabledNotifier.addListener(_onFabEnabledNotifierChanged);
97
- devInspectorRevealNowNotifier.addListener(_onRevealNow);
128
+ devInspectorEnabledNotifier.addListener(_handleEnabledChanged);
98
129
  devInspectorCopyTriggerNotifier.addListener(_onCopyTriggered);
99
- unawaited(_bootstrapFabPreference());
130
+ HardwareKeyboard.instance.addHandler(_handleKeyEvent);
131
+ unawaited(_bootstrapEnabledPreference());
100
132
  }
101
133
 
102
134
  @override
103
135
  void dispose() {
104
- _revealTimer?.cancel();
105
- _fabDeemphasizeTimer?.cancel();
106
136
  _copyFeedbackTimer?.cancel();
107
137
  _highlightTicker?.dispose();
108
- devInspectorFabEnabledNotifier.removeListener(_onFabEnabledNotifierChanged);
109
- devInspectorRevealNowNotifier.removeListener(_onRevealNow);
138
+ _transitionCtrl.dispose();
139
+ HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
140
+ devInspectorEnabledNotifier.removeListener(_handleEnabledChanged);
110
141
  devInspectorCopyTriggerNotifier.removeListener(_onCopyTriggered);
111
142
  devInspectorActiveNotifier.removeListener(_handleActiveChanged);
112
143
  if (devInspectorActiveNotifier.value) {
@@ -115,103 +146,170 @@ class _DevInspectorState extends State<DevInspector>
115
146
  super.dispose();
116
147
  }
117
148
 
118
- Future<void> _bootstrapFabPreference() async {
119
- final SharedPreferences prefs = await SharedPreferences.getInstance();
149
+ void _handleTransitionTick() {
120
150
  if (!mounted) return;
121
- final bool enabled = prefs.getBool(devInspectorFabEnabledPrefKey) ?? false;
122
- final bool changed = devInspectorFabEnabledNotifier.value != enabled;
123
- devInspectorFabEnabledNotifier.value = enabled;
124
- if (!changed) {
125
- _applyFabEnabled(enabled);
151
+ final Rect? from = _transitionFromRect;
152
+ final Rect? to = _transitionToRect;
153
+ if (from == null || to == null) return;
154
+ final double t = Curves.easeOutCubic.transform(_transitionCtrl.value);
155
+ setState(() => _highlightRect = Rect.lerp(from, to, t));
156
+
157
+ final Rect? gFrom = _transitionFromGlobalRect;
158
+ final Rect? gTo = _transitionToGlobalRect;
159
+ if (gFrom != null && gTo != null) {
160
+ devInspectorHighlightGlobalRect.value = Rect.lerp(gFrom, gTo, t);
126
161
  }
127
162
  }
128
163
 
129
- void _onFabEnabledNotifierChanged() {
130
- _applyFabEnabled(devInspectorFabEnabledNotifier.value);
164
+ /// Global keyboard shortcuts. The "wake up" combo works from anywhere,
165
+ /// even when the inspector is off; the rest only fire when it's active.
166
+ /// • Cmd/Ctrl + Shift + P → toggle inspector on/off (debug only)
167
+ /// • Esc → deactivate (and persist OFF)
168
+ /// • C → copy the selected widget (no modifier; the
169
+ /// usual Cmd/Ctrl+C still copies text elsewhere)
170
+ /// • ← / → → step backward / forward through history
171
+ bool _handleKeyEvent(KeyEvent event) {
172
+ if (event is! KeyDownEvent) return false;
173
+ final HardwareKeyboard kb = HardwareKeyboard.instance;
174
+
175
+ // Cmd/Ctrl + Shift + P — works regardless of active state.
176
+ if (kDebugMode &&
177
+ event.logicalKey == LogicalKeyboardKey.keyP &&
178
+ (kb.isMetaPressed || kb.isControlPressed) &&
179
+ kb.isShiftPressed &&
180
+ !kb.isAltPressed) {
181
+ _toggleInspectorFromShortcut();
182
+ return true;
183
+ }
184
+
185
+ if (!_active) return false;
186
+
187
+ final bool hasModifier = kb.isMetaPressed ||
188
+ kb.isControlPressed ||
189
+ kb.isAltPressed ||
190
+ kb.isShiftPressed;
191
+
192
+ if (event.logicalKey == LogicalKeyboardKey.escape) {
193
+ // Esc must turn the inspector off no matter HOW it was activated:
194
+ // • Admin toggle → flip Enabled (and let it cascade to Active).
195
+ // • WebDevicePreview pill → only Active is set; flip it directly.
196
+ // We touch both so ValueNotifier always observes a real change.
197
+ if (devInspectorEnabledNotifier.value) {
198
+ devInspectorEnabledNotifier.value = false;
199
+ }
200
+ if (devInspectorActiveNotifier.value) {
201
+ devInspectorActiveNotifier.value = false;
202
+ }
203
+ HapticFeedback.lightImpact();
204
+ return true;
205
+ }
206
+ if (event.logicalKey == LogicalKeyboardKey.keyC && !hasModifier) {
207
+ if (_selectedInfo == null) return false;
208
+ unawaited(_copySelection());
209
+ return true;
210
+ }
211
+ if (event.logicalKey == LogicalKeyboardKey.arrowLeft && !hasModifier) {
212
+ return _stepHistory(-1);
213
+ }
214
+ if (event.logicalKey == LogicalKeyboardKey.arrowRight && !hasModifier) {
215
+ return _stepHistory(1);
216
+ }
217
+ return false;
131
218
  }
132
219
 
133
- void _onCopyTriggered() {
134
- if (!devInspectorCopyTriggerNotifier.value) return;
135
- devInspectorCopyTriggerNotifier.value = false;
136
- if (!_active) return;
137
- unawaited(_copySelection());
220
+ /// Flip the inspector on or off via the global shortcut. Mirrors what the
221
+ /// admin toggle does on the ON side, and what Esc does on the OFF side.
222
+ void _toggleInspectorFromShortcut() {
223
+ final bool turningOn = !_active;
224
+ if (turningOn) {
225
+ devInspectorEnabledNotifier.value = true;
226
+ } else {
227
+ if (devInspectorEnabledNotifier.value) {
228
+ devInspectorEnabledNotifier.value = false;
229
+ }
230
+ if (devInspectorActiveNotifier.value) {
231
+ devInspectorActiveNotifier.value = false;
232
+ }
233
+ }
234
+ HapticFeedback.lightImpact();
138
235
  }
139
236
 
140
- void _onRevealNow() {
141
- if (!devInspectorRevealNowNotifier.value) return;
142
- devInspectorRevealNowNotifier.value = false;
143
- if (!devInspectorFabEnabledNotifier.value || !mounted) return;
144
- _revealTimer?.cancel();
237
+ bool _stepHistory(int delta) {
238
+ if (_history.isEmpty) return false;
239
+ final int next = _historyCursor + delta;
240
+ if (next < 0 || next >= _history.length) return false;
241
+ _historyCursor = next;
242
+ final entry = _history[next];
243
+ if (!entry.renderObject.attached) return false;
244
+
245
+ final Rect? newRect = _rectInOverlaySpace(entry.renderObject);
246
+ final Rect? newGlobalRect = _rectInRootSpace(entry.renderObject);
247
+ final Rect? fromRect = _highlightRect;
248
+ final Rect? fromGlobalRect = devInspectorHighlightGlobalRect.value;
249
+
250
+ _navigatingHistory = true;
145
251
  setState(() {
146
- _fabLayerMounted = true;
147
- _fabFadeIn = false;
148
- });
149
- WidgetsBinding.instance.addPostFrameCallback((_) {
150
- if (!mounted) return;
151
- setState(() => _fabFadeIn = true);
252
+ _selectedInfo = entry.info;
253
+ _selectedRender = entry.renderObject;
152
254
  });
255
+ _animateHighlightTo(
256
+ fromRect: fromRect,
257
+ toRect: newRect,
258
+ fromGlobalRect: fromGlobalRect,
259
+ toGlobalRect: newGlobalRect,
260
+ );
261
+ _navigatingHistory = false;
262
+ HapticFeedback.selectionClick();
263
+ return true;
153
264
  }
154
265
 
155
- void _applyFabEnabled(bool enabled) {
156
- _revealTimer?.cancel();
157
- _fabDeemphasizeTimer?.cancel();
266
+ Future<void> _bootstrapEnabledPreference() async {
267
+ final SharedPreferences prefs = await SharedPreferences.getInstance();
158
268
  if (!mounted) return;
269
+ final bool enabled = prefs.getBool(devInspectorEnabledPrefKey) ?? false;
270
+ if (devInspectorEnabledNotifier.value != enabled) {
271
+ devInspectorEnabledNotifier.value = enabled;
272
+ } else {
273
+ _applyEnabled(enabled);
274
+ }
275
+ }
276
+
277
+ Future<void> _persistEnabled(bool value) async {
278
+ try {
279
+ final SharedPreferences prefs = await SharedPreferences.getInstance();
280
+ await prefs.setBool(devInspectorEnabledPrefKey, value);
281
+ } catch (_) {
282
+ // Best-effort persistence — don't crash the inspector if prefs fail.
283
+ }
284
+ }
285
+
286
+ void _handleEnabledChanged() {
287
+ _applyEnabled(devInspectorEnabledNotifier.value);
288
+ unawaited(_persistEnabled(devInspectorEnabledNotifier.value));
289
+ }
290
+
291
+ void _applyEnabled(bool enabled) {
292
+ if (!mounted) return;
293
+ // Master switch directly drives the runtime active flag — no FAB stage.
294
+ if (devInspectorActiveNotifier.value != enabled) {
295
+ devInspectorActiveNotifier.value = enabled;
296
+ }
159
297
  if (!enabled) {
160
- if (_active) {
161
- devInspectorActiveNotifier.value = false;
162
- }
163
298
  _copyFeedbackTimer?.cancel();
164
299
  setState(() {
165
300
  _active = false;
166
- _fabLayerMounted = false;
167
- _fabFadeIn = false;
168
- _fabEmphasized = false;
169
- _dragging = false;
170
301
  _copyFeedbackText = null;
171
302
  _copyFeedbackIsError = false;
172
303
  _clearSelection();
173
304
  });
174
- _copyFeedbackTimer?.cancel();
175
- return;
176
305
  }
177
- setState(() {
178
- _fabLayerMounted = false;
179
- _fabFadeIn = false;
180
- });
181
- _revealTimer = Timer(_fabRevealDelay, _onFabRevealDelayElapsed);
182
- }
183
-
184
- void _onFabRevealDelayElapsed() {
185
- if (!mounted) return;
186
- setState(() {
187
- _fabLayerMounted = true;
188
- _fabFadeIn = false;
189
- });
190
- WidgetsBinding.instance.addPostFrameCallback((_) {
191
- if (!mounted) return;
192
- setState(() => _fabFadeIn = true);
193
- });
194
- }
195
-
196
- void _scheduleFabDeemphasize() {
197
- _fabDeemphasizeTimer?.cancel();
198
- _fabDeemphasizeTimer = Timer(_fabDeemphasizeDelay, () {
199
- if (!mounted) return;
200
- setState(() => _fabEmphasized = false);
201
- _fabDeemphasizeTimer = null;
202
- });
203
306
  }
204
307
 
205
- double get _fabAnimatedOpacity {
206
- if (!_fabLayerMounted) return 0.0;
207
- if (!_fabFadeIn) return 0.0;
208
- if (_dragging || _copyBusy || _fabEmphasized) return 1.0;
209
- return _active ? _fabOpacityInspectorOn : _fabOpacityIdle;
210
- }
211
-
212
- void _setInspectorActive(bool value) {
213
- devInspectorActiveNotifier.value = value;
214
- HapticFeedback.lightImpact();
308
+ void _onCopyTriggered() {
309
+ if (!devInspectorCopyTriggerNotifier.value) return;
310
+ devInspectorCopyTriggerNotifier.value = false;
311
+ if (!_active) return;
312
+ unawaited(_copySelection());
215
313
  }
216
314
 
217
315
  void _handleActiveChanged() {
@@ -228,36 +326,138 @@ class _DevInspectorState extends State<DevInspector>
228
326
  _selectedInfo = null;
229
327
  _selectedRender = null;
230
328
  _highlightRect = null;
329
+ _transitionFromRect = null;
330
+ _transitionToRect = null;
331
+ _transitionFromGlobalRect = null;
332
+ _transitionToGlobalRect = null;
333
+ if (_transitionCtrl.isAnimating) _transitionCtrl.stop();
231
334
  _stopHighlightTicker();
335
+ devInspectorHighlightGlobalRect.value = null;
336
+ _history.clear();
337
+ _historyCursor = -1;
338
+ }
339
+
340
+ void _pushHistory(DevInspectorInfo info, RenderObject renderObject) {
341
+ // Drop anything ahead of the cursor — selecting a new widget after
342
+ // pressing ← invalidates the "forward" history (same as a browser).
343
+ if (_historyCursor < _history.length - 1) {
344
+ _history.removeRange(_historyCursor + 1, _history.length);
345
+ }
346
+ _history.add((info: info, renderObject: renderObject));
347
+ if (_history.length > _historyCapacity) {
348
+ _history.removeAt(0);
349
+ }
350
+ _historyCursor = _history.length - 1;
232
351
  }
233
352
 
234
353
  void _onInspectorTap(Offset globalPosition) {
235
- final picked = DevInspectorService.pickAt(globalPosition);
354
+ final RenderObject? content = _contentKey.currentContext?.findRenderObject();
355
+ if (content is! RenderBox) {
356
+ HapticFeedback.heavyImpact();
357
+ return;
358
+ }
359
+ var picked = DevInspectorService.pickAtInBox(content, globalPosition);
236
360
  if (picked == null) {
237
361
  HapticFeedback.heavyImpact();
238
362
  return;
239
363
  }
364
+
365
+ // Repeat-click bubbles the selection up: if the tap landed on the SAME
366
+ // widget that's already selected, climb one level up the meaningful
367
+ // hierarchy instead of re-selecting it.
368
+ if (_selectedRender != null &&
369
+ identical(picked.renderObject, _selectedRender)) {
370
+ final climbed = DevInspectorService.climbFrom(picked.renderObject);
371
+ if (climbed != null) picked = climbed;
372
+ }
373
+
374
+ final Rect? newRect = _rectInOverlaySpace(picked.renderObject);
375
+ final Rect? newGlobalRect = _rectInRootSpace(picked.renderObject);
376
+ final Rect? fromRect = _highlightRect;
377
+ final Rect? fromGlobalRect = devInspectorHighlightGlobalRect.value;
378
+
240
379
  setState(() {
241
- _selectedInfo = picked.info;
380
+ _selectedInfo = picked!.info;
242
381
  _selectedRender = picked.renderObject;
243
- _highlightRect = _toOverlayLocal(picked.info.boundingBox);
244
382
  });
245
- _startHighlightTicker();
383
+
384
+ if (!_navigatingHistory) {
385
+ _pushHistory(picked.info, picked.renderObject);
386
+ }
387
+
388
+ _animateHighlightTo(
389
+ fromRect: fromRect,
390
+ toRect: newRect,
391
+ fromGlobalRect: fromGlobalRect,
392
+ toGlobalRect: newGlobalRect,
393
+ );
246
394
  HapticFeedback.selectionClick();
247
395
  }
248
396
 
249
- /// Converts a rect expressed in the root view's coordinate space into the
250
- /// overlay's local space, so it draws correctly even when the overlay sits
251
- /// inside a transformed parent (e.g. WebDevicePreview's device frame).
252
- Rect? _toOverlayLocal(Rect rootRect) {
253
- final RenderObject? renderObject =
397
+ /// Animates the highlight from [fromRect] to [toRect] in ~140 ms, then hands
398
+ /// off to the per-frame ticker (which keeps the highlight glued to the
399
+ /// widget if it animates internally). If there's no previous rect or no
400
+ /// change in geometry, the new rect is applied instantly.
401
+ void _animateHighlightTo({
402
+ required Rect? fromRect,
403
+ required Rect? toRect,
404
+ required Rect? fromGlobalRect,
405
+ required Rect? toGlobalRect,
406
+ }) {
407
+ _stopHighlightTicker();
408
+ if (_transitionCtrl.isAnimating) _transitionCtrl.stop();
409
+
410
+ if (fromRect == null || toRect == null || fromRect == toRect) {
411
+ setState(() => _highlightRect = toRect);
412
+ devInspectorHighlightGlobalRect.value = toGlobalRect;
413
+ if (toRect != null && _selectedRender != null) _startHighlightTicker();
414
+ return;
415
+ }
416
+
417
+ _transitionFromRect = fromRect;
418
+ _transitionToRect = toRect;
419
+ _transitionFromGlobalRect = fromGlobalRect;
420
+ _transitionToGlobalRect = toGlobalRect;
421
+ _transitionCtrl.forward(from: 0.0).whenComplete(() {
422
+ if (!mounted) return;
423
+ if (_transitionCtrl.status != AnimationStatus.completed) return;
424
+ setState(() => _highlightRect = toRect);
425
+ devInspectorHighlightGlobalRect.value = toGlobalRect;
426
+ _transitionFromRect = null;
427
+ _transitionToRect = null;
428
+ _transitionFromGlobalRect = null;
429
+ _transitionToGlobalRect = null;
430
+ if (_selectedRender != null) _startHighlightTicker();
431
+ });
432
+ }
433
+
434
+ /// Computes the bounds of [target] in the overlay's local coordinate space,
435
+ /// walking the paint-transform matrix directly. This works regardless of any
436
+ /// transforms applied above the overlay (e.g. WebDevicePreview's scale).
437
+ Rect? _rectInOverlaySpace(RenderObject target) {
438
+ if (!target.attached) return null;
439
+ final RenderObject? overlay =
254
440
  _overlayKey.currentContext?.findRenderObject();
255
- if (renderObject is! RenderBox) return rootRect;
256
- if (!renderObject.attached) return rootRect;
257
- final Offset topLeft = renderObject.globalToLocal(rootRect.topLeft);
258
- final Offset bottomRight =
259
- renderObject.globalToLocal(rootRect.bottomRight);
260
- return Rect.fromPoints(topLeft, bottomRight);
441
+ if (overlay == null || !overlay.attached) return null;
442
+ try {
443
+ final Matrix4 transform = target.getTransformTo(overlay);
444
+ return MatrixUtils.transformRect(transform, target.paintBounds);
445
+ } catch (_) {
446
+ return null;
447
+ }
448
+ }
449
+
450
+ /// Computes the bounds of [target] in **root view coordinates** (the browser
451
+ /// window / native window). Used to drive the external highlight overlay
452
+ /// that sits ABOVE the device frame in WebDevicePreview.
453
+ Rect? _rectInRootSpace(RenderObject target) {
454
+ if (!target.attached) return null;
455
+ try {
456
+ final Matrix4 transform = target.getTransformTo(null);
457
+ return MatrixUtils.transformRect(transform, target.paintBounds);
458
+ } catch (_) {
459
+ return null;
460
+ }
261
461
  }
262
462
 
263
463
  void _startHighlightTicker() {
@@ -272,15 +472,18 @@ class _DevInspectorState extends State<DevInspector>
272
472
  void _onHighlightTick(Duration _) {
273
473
  final RenderObject? target = _selectedRender;
274
474
  if (target == null) return;
275
- final Rect? rect = DevInspectorService.remeasure(target);
276
- if (rect == null) {
475
+ if (!target.attached) {
277
476
  setState(_clearSelection);
278
477
  return;
279
478
  }
280
- final Rect? local = _toOverlayLocal(rect);
281
- if (_highlightRect != local) {
479
+ final Rect? local = _rectInOverlaySpace(target);
480
+ if (local != null && _highlightRect != local) {
282
481
  setState(() => _highlightRect = local);
283
482
  }
483
+ final Rect? global = _rectInRootSpace(target);
484
+ if (global != devInspectorHighlightGlobalRect.value) {
485
+ devInspectorHighlightGlobalRect.value = global;
486
+ }
284
487
  }
285
488
 
286
489
  String? _extractRouteHint(DevInspectorInfo info) {
@@ -375,17 +578,10 @@ class _DevInspectorState extends State<DevInspector>
375
578
  data: shellTheme,
376
579
  child: Builder(
377
580
  builder: (BuildContext context) {
378
- final KasyColors colors = context.colors;
379
-
380
- // Default: bottom-right, just above the home bottom nav (still draggable).
381
- // ~16px from right; larger bottom inset → sits a bit higher above the bar.
382
- _fabDx ??= size.width - 68.0;
383
- _fabDy ??= size.height - padding.bottom - 152.0;
384
-
385
581
  return Stack(
386
582
  clipBehavior: Clip.none,
387
583
  children: <Widget>[
388
- widget.child,
584
+ KeyedSubtree(key: _contentKey, child: widget.child),
389
585
  if (_active)
390
586
  Positioned.fill(
391
587
  child: Listener(
@@ -401,55 +597,12 @@ class _DevInspectorState extends State<DevInspector>
401
597
  ),
402
598
  ),
403
599
  ),
404
- if (_fabLayerMounted &&
405
- devInspectorFabEnabledNotifier.value)
600
+ if (_active)
406
601
  Positioned(
407
- left: _fabDx,
408
- top: _fabDy,
409
- child: AnimatedOpacity(
410
- opacity: _fabAnimatedOpacity,
411
- duration: _fabOpacityAnimation,
412
- curve: Curves.easeOutCubic,
413
- child: GestureDetector(
414
- onTapDown: (_) => setState(() => _fabEmphasized = true),
415
- onTap: () {
416
- if (_active) {
417
- _copySelection().whenComplete(
418
- _scheduleFabDeemphasize,
419
- );
420
- } else {
421
- _setInspectorActive(true);
422
- _scheduleFabDeemphasize();
423
- }
424
- },
425
- onPanStart: (_) => setState(() {
426
- _dragging = true;
427
- _fabEmphasized = true;
428
- }),
429
- onPanUpdate: (DragUpdateDetails details) {
430
- setState(() {
431
- _fabDx = (_fabDx! + details.delta.dx).clamp(
432
- 8.0,
433
- size.width - 60.0,
434
- );
435
- _fabDy = (_fabDy! + details.delta.dy).clamp(
436
- padding.top + 8.0,
437
- size.height - padding.bottom - 60.0,
438
- );
439
- });
440
- },
441
- onPanEnd: (_) {
442
- if (!mounted) return;
443
- setState(() => _dragging = false);
444
- _scheduleFabDeemphasize();
445
- },
446
- child: _EntryFab(
447
- dragging: _dragging,
448
- active: _active,
449
- busy: _copyBusy,
450
- colors: colors,
451
- ),
452
- ),
602
+ bottom: padding.bottom + 12,
603
+ right: 12,
604
+ child: const IgnorePointer(
605
+ child: _InspectorStatusPill(),
453
606
  ),
454
607
  ),
455
608
  if (_copyFeedbackText != null)
@@ -500,62 +653,6 @@ class _DevInspectorState extends State<DevInspector>
500
653
  }
501
654
  }
502
655
 
503
- class _EntryFab extends StatelessWidget {
504
- const _EntryFab({
505
- required this.dragging,
506
- required this.active,
507
- required this.busy,
508
- required this.colors,
509
- });
510
-
511
- final bool dragging;
512
- final bool active;
513
- final bool busy;
514
- final KasyColors colors;
515
-
516
- @override
517
- Widget build(BuildContext context) {
518
- return Semantics(
519
- label: active ? t.devInspector.copyForAi : t.devInspector.activate,
520
- button: true,
521
- child: AnimatedContainer(
522
- duration: const Duration(milliseconds: 180),
523
- width: 52,
524
- height: 52,
525
- decoration: BoxDecoration(
526
- color: colors.surface,
527
- shape: BoxShape.circle,
528
- border: Border.all(
529
- color: colors.onSurface.withValues(alpha: 0.14),
530
- width: 1.5,
531
- ),
532
- boxShadow: <BoxShadow>[
533
- BoxShadow(
534
- color: colors.onSurface.withValues(alpha: dragging ? 0.20 : 0.08),
535
- blurRadius: dragging ? 18 : 10,
536
- offset: Offset(0, dragging ? 6 : 3),
537
- ),
538
- ],
539
- ),
540
- child: busy
541
- ? SizedBox(
542
- width: 20,
543
- height: 20,
544
- child: CircularProgressIndicator(
545
- strokeWidth: 2,
546
- valueColor: AlwaysStoppedAnimation<Color>(colors.primary),
547
- ),
548
- )
549
- : Icon(
550
- active ? KasyIcons.copy : KasyIcons.widgets,
551
- color: colors.primary,
552
- size: 22,
553
- ),
554
- ),
555
- );
556
- }
557
- }
558
-
559
656
  class _HighlightPainter extends CustomPainter {
560
657
  _HighlightPainter({required this.rect});
561
658
 
@@ -581,3 +678,96 @@ class _HighlightPainter extends CustomPainter {
581
678
  bool shouldRepaint(_HighlightPainter oldDelegate) =>
582
679
  oldDelegate.rect != rect;
583
680
  }
681
+
682
+ /// Minimal status pill tucked in the bottom-right corner while the inspector
683
+ /// is active. Mirrors the role of the WebDevicePreview chrome (which already
684
+ /// shows inspector state); hidden when that chrome is present via
685
+ /// [devInspectorSuppressStatusPillNotifier].
686
+ class _InspectorStatusPill extends StatelessWidget {
687
+ const _InspectorStatusPill();
688
+
689
+ @override
690
+ Widget build(BuildContext context) {
691
+ return ValueListenableBuilder<bool>(
692
+ valueListenable: devInspectorSuppressStatusPillNotifier,
693
+ builder: (BuildContext context, bool suppressed, Widget? _) {
694
+ if (suppressed) return const SizedBox.shrink();
695
+ return Container(
696
+ padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
697
+ decoration: BoxDecoration(
698
+ color: const Color(0xB3111111),
699
+ borderRadius: BorderRadius.circular(999),
700
+ boxShadow: const <BoxShadow>[
701
+ BoxShadow(
702
+ color: Color(0x33000000),
703
+ blurRadius: 8,
704
+ offset: Offset(0, 2),
705
+ ),
706
+ ],
707
+ ),
708
+ child: Row(
709
+ mainAxisSize: MainAxisSize.min,
710
+ children: <Widget>[
711
+ const _PulsingDot(),
712
+ const SizedBox(width: 7),
713
+ Text(
714
+ t.devInspector.statusActive,
715
+ style: const TextStyle(
716
+ color: Color(0xE6FFFFFF),
717
+ fontSize: 10.5,
718
+ fontWeight: FontWeight.w500,
719
+ letterSpacing: 0.2,
720
+ ),
721
+ ),
722
+ ],
723
+ ),
724
+ );
725
+ },
726
+ );
727
+ }
728
+ }
729
+
730
+ class _PulsingDot extends StatefulWidget {
731
+ const _PulsingDot();
732
+ @override
733
+ State<_PulsingDot> createState() => _PulsingDotState();
734
+ }
735
+
736
+ class _PulsingDotState extends State<_PulsingDot>
737
+ with SingleTickerProviderStateMixin {
738
+ late final AnimationController _ctrl;
739
+
740
+ @override
741
+ void initState() {
742
+ super.initState();
743
+ _ctrl = AnimationController(
744
+ vsync: this,
745
+ duration: const Duration(milliseconds: 1100),
746
+ )..repeat(reverse: true);
747
+ }
748
+
749
+ @override
750
+ void dispose() {
751
+ _ctrl.dispose();
752
+ super.dispose();
753
+ }
754
+
755
+ @override
756
+ Widget build(BuildContext context) {
757
+ return AnimatedBuilder(
758
+ animation: _ctrl,
759
+ builder: (BuildContext context, Widget? _) {
760
+ final double t = Curves.easeInOut.transform(_ctrl.value);
761
+ final double alpha = 0.45 + 0.55 * t;
762
+ return Container(
763
+ width: 7,
764
+ height: 7,
765
+ decoration: BoxDecoration(
766
+ color: _highlightColor.withValues(alpha: alpha),
767
+ shape: BoxShape.circle,
768
+ ),
769
+ );
770
+ },
771
+ );
772
+ }
773
+ }