kasy-cli 1.19.1 → 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 (44) hide show
  1. package/bin/kasy.js +1 -0
  2. package/lib/commands/new.js +9 -0
  3. package/lib/commands/run.js +22 -6
  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/flutter-run.js +16 -4
  9. package/lib/utils/i18n/messages-en.js +2 -1
  10. package/lib/utils/i18n/messages-es.js +2 -1
  11. package/lib/utils/i18n/messages-pt.js +2 -1
  12. package/package.json +1 -1
  13. package/templates/firebase/android/app/src/main/res/drawable/background.png +0 -0
  14. package/templates/firebase/android/app/src/main/res/drawable-night/background.png +0 -0
  15. package/templates/firebase/android/app/src/main/res/drawable-night-v21/background.png +0 -0
  16. package/templates/firebase/android/app/src/main/res/drawable-v21/background.png +0 -0
  17. package/templates/firebase/android/app/src/main/res/values-night-v31/styles.xml +1 -1
  18. package/templates/firebase/android/app/src/main/res/values-v31/styles.xml +1 -1
  19. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png +0 -0
  20. package/templates/firebase/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png +0 -0
  21. package/templates/firebase/lib/components/kasy_date_picker.dart +14 -8
  22. package/templates/firebase/lib/components/kasy_sidebar_pro.dart +1148 -0
  23. package/templates/firebase/lib/components/kasy_tabs.dart +156 -43
  24. package/templates/firebase/lib/components/kasy_text_field.dart +39 -29
  25. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +90 -69
  26. package/templates/firebase/lib/core/bottom_menu/bottom_router.dart +30 -7
  27. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +8 -1
  28. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +439 -232
  29. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +198 -83
  30. package/templates/firebase/lib/core/icons/kasy_icons.dart +1 -0
  31. package/templates/firebase/lib/core/theme/colors.dart +6 -2
  32. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +119 -19
  33. package/templates/firebase/lib/core/widgets/kasy_hover.dart +68 -27
  34. package/templates/firebase/lib/features/home/home_components_page.dart +3 -0
  35. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +202 -79
  36. package/templates/firebase/lib/features/settings/settings_page.dart +27 -146
  37. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +16 -3
  38. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +22 -5
  39. package/templates/firebase/lib/i18n/en.i18n.json +3 -1
  40. package/templates/firebase/lib/i18n/es.i18n.json +3 -1
  41. package/templates/firebase/lib/i18n/pt.i18n.json +3 -1
  42. package/templates/firebase/pubspec.yaml +6 -4
  43. package/templates/firebase/web/index.html +7 -17
  44. 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,55 +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;
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;
90
119
 
91
120
  @override
92
121
  void initState() {
93
122
  super.initState();
123
+ _transitionCtrl = AnimationController(
124
+ vsync: this,
125
+ duration: const Duration(milliseconds: 140),
126
+ )..addListener(_handleTransitionTick);
94
127
  devInspectorActiveNotifier.addListener(_handleActiveChanged);
95
- devInspectorFabEnabledNotifier.addListener(_onFabEnabledNotifierChanged);
96
- devInspectorRevealNowNotifier.addListener(_onRevealNow);
128
+ devInspectorEnabledNotifier.addListener(_handleEnabledChanged);
97
129
  devInspectorCopyTriggerNotifier.addListener(_onCopyTriggered);
98
- unawaited(_bootstrapFabPreference());
130
+ HardwareKeyboard.instance.addHandler(_handleKeyEvent);
131
+ unawaited(_bootstrapEnabledPreference());
99
132
  }
100
133
 
101
134
  @override
102
135
  void dispose() {
103
- _revealTimer?.cancel();
104
- _fabDeemphasizeTimer?.cancel();
105
136
  _copyFeedbackTimer?.cancel();
106
137
  _highlightTicker?.dispose();
107
- devInspectorFabEnabledNotifier.removeListener(_onFabEnabledNotifierChanged);
108
- devInspectorRevealNowNotifier.removeListener(_onRevealNow);
138
+ _transitionCtrl.dispose();
139
+ HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
140
+ devInspectorEnabledNotifier.removeListener(_handleEnabledChanged);
109
141
  devInspectorCopyTriggerNotifier.removeListener(_onCopyTriggered);
110
142
  devInspectorActiveNotifier.removeListener(_handleActiveChanged);
111
143
  if (devInspectorActiveNotifier.value) {
@@ -114,103 +146,170 @@ class _DevInspectorState extends State<DevInspector>
114
146
  super.dispose();
115
147
  }
116
148
 
117
- Future<void> _bootstrapFabPreference() async {
118
- final SharedPreferences prefs = await SharedPreferences.getInstance();
149
+ void _handleTransitionTick() {
119
150
  if (!mounted) return;
120
- final bool enabled = prefs.getBool(devInspectorFabEnabledPrefKey) ?? false;
121
- final bool changed = devInspectorFabEnabledNotifier.value != enabled;
122
- devInspectorFabEnabledNotifier.value = enabled;
123
- if (!changed) {
124
- _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);
125
161
  }
126
162
  }
127
163
 
128
- void _onFabEnabledNotifierChanged() {
129
- _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;
130
218
  }
131
219
 
132
- void _onCopyTriggered() {
133
- if (!devInspectorCopyTriggerNotifier.value) return;
134
- devInspectorCopyTriggerNotifier.value = false;
135
- if (!_active) return;
136
- 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();
137
235
  }
138
236
 
139
- void _onRevealNow() {
140
- if (!devInspectorRevealNowNotifier.value) return;
141
- devInspectorRevealNowNotifier.value = false;
142
- if (!devInspectorFabEnabledNotifier.value || !mounted) return;
143
- _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;
144
251
  setState(() {
145
- _fabLayerMounted = true;
146
- _fabFadeIn = false;
147
- });
148
- WidgetsBinding.instance.addPostFrameCallback((_) {
149
- if (!mounted) return;
150
- setState(() => _fabFadeIn = true);
252
+ _selectedInfo = entry.info;
253
+ _selectedRender = entry.renderObject;
151
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;
152
264
  }
153
265
 
154
- void _applyFabEnabled(bool enabled) {
155
- _revealTimer?.cancel();
156
- _fabDeemphasizeTimer?.cancel();
266
+ Future<void> _bootstrapEnabledPreference() async {
267
+ final SharedPreferences prefs = await SharedPreferences.getInstance();
157
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
+ }
158
297
  if (!enabled) {
159
- if (_active) {
160
- devInspectorActiveNotifier.value = false;
161
- }
162
298
  _copyFeedbackTimer?.cancel();
163
299
  setState(() {
164
300
  _active = false;
165
- _fabLayerMounted = false;
166
- _fabFadeIn = false;
167
- _fabEmphasized = false;
168
- _dragging = false;
169
301
  _copyFeedbackText = null;
170
302
  _copyFeedbackIsError = false;
171
303
  _clearSelection();
172
304
  });
173
- _copyFeedbackTimer?.cancel();
174
- return;
175
305
  }
176
- setState(() {
177
- _fabLayerMounted = false;
178
- _fabFadeIn = false;
179
- });
180
- _revealTimer = Timer(_fabRevealDelay, _onFabRevealDelayElapsed);
181
- }
182
-
183
- void _onFabRevealDelayElapsed() {
184
- if (!mounted) return;
185
- setState(() {
186
- _fabLayerMounted = true;
187
- _fabFadeIn = false;
188
- });
189
- WidgetsBinding.instance.addPostFrameCallback((_) {
190
- if (!mounted) return;
191
- setState(() => _fabFadeIn = true);
192
- });
193
306
  }
194
307
 
195
- void _scheduleFabDeemphasize() {
196
- _fabDeemphasizeTimer?.cancel();
197
- _fabDeemphasizeTimer = Timer(_fabDeemphasizeDelay, () {
198
- if (!mounted) return;
199
- setState(() => _fabEmphasized = false);
200
- _fabDeemphasizeTimer = null;
201
- });
202
- }
203
-
204
- double get _fabAnimatedOpacity {
205
- if (!_fabLayerMounted) return 0.0;
206
- if (!_fabFadeIn) return 0.0;
207
- if (_dragging || _copyBusy || _fabEmphasized) return 1.0;
208
- return _active ? _fabOpacityInspectorOn : _fabOpacityIdle;
209
- }
210
-
211
- void _setInspectorActive(bool value) {
212
- devInspectorActiveNotifier.value = value;
213
- HapticFeedback.lightImpact();
308
+ void _onCopyTriggered() {
309
+ if (!devInspectorCopyTriggerNotifier.value) return;
310
+ devInspectorCopyTriggerNotifier.value = false;
311
+ if (!_active) return;
312
+ unawaited(_copySelection());
214
313
  }
215
314
 
216
315
  void _handleActiveChanged() {
@@ -227,24 +326,140 @@ class _DevInspectorState extends State<DevInspector>
227
326
  _selectedInfo = null;
228
327
  _selectedRender = null;
229
328
  _highlightRect = null;
329
+ _transitionFromRect = null;
330
+ _transitionToRect = null;
331
+ _transitionFromGlobalRect = null;
332
+ _transitionToGlobalRect = null;
333
+ if (_transitionCtrl.isAnimating) _transitionCtrl.stop();
230
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;
231
351
  }
232
352
 
233
353
  void _onInspectorTap(Offset globalPosition) {
234
- 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);
235
360
  if (picked == null) {
236
361
  HapticFeedback.heavyImpact();
237
362
  return;
238
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
+
239
379
  setState(() {
240
- _selectedInfo = picked.info;
380
+ _selectedInfo = picked!.info;
241
381
  _selectedRender = picked.renderObject;
242
- _highlightRect = picked.info.boundingBox;
243
382
  });
244
- _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
+ );
245
394
  HapticFeedback.selectionClick();
246
395
  }
247
396
 
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 =
440
+ _overlayKey.currentContext?.findRenderObject();
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
+ }
461
+ }
462
+
248
463
  void _startHighlightTicker() {
249
464
  _highlightTicker ??= createTicker(_onHighlightTick);
250
465
  if (!_highlightTicker!.isActive) _highlightTicker!.start();
@@ -257,13 +472,17 @@ class _DevInspectorState extends State<DevInspector>
257
472
  void _onHighlightTick(Duration _) {
258
473
  final RenderObject? target = _selectedRender;
259
474
  if (target == null) return;
260
- final Rect? rect = DevInspectorService.remeasure(target);
261
- if (rect == null) {
475
+ if (!target.attached) {
262
476
  setState(_clearSelection);
263
477
  return;
264
478
  }
265
- if (_highlightRect != rect) {
266
- setState(() => _highlightRect = rect);
479
+ final Rect? local = _rectInOverlaySpace(target);
480
+ if (local != null && _highlightRect != local) {
481
+ setState(() => _highlightRect = local);
482
+ }
483
+ final Rect? global = _rectInRootSpace(target);
484
+ if (global != devInspectorHighlightGlobalRect.value) {
485
+ devInspectorHighlightGlobalRect.value = global;
267
486
  }
268
487
  }
269
488
 
@@ -359,17 +578,10 @@ class _DevInspectorState extends State<DevInspector>
359
578
  data: shellTheme,
360
579
  child: Builder(
361
580
  builder: (BuildContext context) {
362
- final KasyColors colors = context.colors;
363
-
364
- // Default: bottom-right, just above the home bottom nav (still draggable).
365
- // ~16px from right; larger bottom inset → sits a bit higher above the bar.
366
- _fabDx ??= size.width - 68.0;
367
- _fabDy ??= size.height - padding.bottom - 152.0;
368
-
369
581
  return Stack(
370
582
  clipBehavior: Clip.none,
371
583
  children: <Widget>[
372
- widget.child,
584
+ KeyedSubtree(key: _contentKey, child: widget.child),
373
585
  if (_active)
374
586
  Positioned.fill(
375
587
  child: Listener(
@@ -378,61 +590,19 @@ class _DevInspectorState extends State<DevInspector>
378
590
  _onInspectorTap(ev.position),
379
591
  child: IgnorePointer(
380
592
  child: CustomPaint(
593
+ key: _overlayKey,
381
594
  painter: _HighlightPainter(rect: _highlightRect),
382
595
  size: Size.infinite,
383
596
  ),
384
597
  ),
385
598
  ),
386
599
  ),
387
- if (_fabLayerMounted &&
388
- devInspectorFabEnabledNotifier.value)
600
+ if (_active)
389
601
  Positioned(
390
- left: _fabDx,
391
- top: _fabDy,
392
- child: AnimatedOpacity(
393
- opacity: _fabAnimatedOpacity,
394
- duration: _fabOpacityAnimation,
395
- curve: Curves.easeOutCubic,
396
- child: GestureDetector(
397
- onTapDown: (_) => setState(() => _fabEmphasized = true),
398
- onTap: () {
399
- if (_active) {
400
- _copySelection().whenComplete(
401
- _scheduleFabDeemphasize,
402
- );
403
- } else {
404
- _setInspectorActive(true);
405
- _scheduleFabDeemphasize();
406
- }
407
- },
408
- onPanStart: (_) => setState(() {
409
- _dragging = true;
410
- _fabEmphasized = true;
411
- }),
412
- onPanUpdate: (DragUpdateDetails details) {
413
- setState(() {
414
- _fabDx = (_fabDx! + details.delta.dx).clamp(
415
- 8.0,
416
- size.width - 60.0,
417
- );
418
- _fabDy = (_fabDy! + details.delta.dy).clamp(
419
- padding.top + 8.0,
420
- size.height - padding.bottom - 60.0,
421
- );
422
- });
423
- },
424
- onPanEnd: (_) {
425
- if (!mounted) return;
426
- setState(() => _dragging = false);
427
- _scheduleFabDeemphasize();
428
- },
429
- child: _EntryFab(
430
- dragging: _dragging,
431
- active: _active,
432
- busy: _copyBusy,
433
- colors: colors,
434
- ),
435
- ),
602
+ bottom: padding.bottom + 12,
603
+ right: 12,
604
+ child: const IgnorePointer(
605
+ child: _InspectorStatusPill(),
436
606
  ),
437
607
  ),
438
608
  if (_copyFeedbackText != null)
@@ -483,62 +653,6 @@ class _DevInspectorState extends State<DevInspector>
483
653
  }
484
654
  }
485
655
 
486
- class _EntryFab extends StatelessWidget {
487
- const _EntryFab({
488
- required this.dragging,
489
- required this.active,
490
- required this.busy,
491
- required this.colors,
492
- });
493
-
494
- final bool dragging;
495
- final bool active;
496
- final bool busy;
497
- final KasyColors colors;
498
-
499
- @override
500
- Widget build(BuildContext context) {
501
- return Semantics(
502
- label: active ? t.devInspector.copyForAi : t.devInspector.activate,
503
- button: true,
504
- child: AnimatedContainer(
505
- duration: const Duration(milliseconds: 180),
506
- width: 52,
507
- height: 52,
508
- decoration: BoxDecoration(
509
- color: colors.surface,
510
- shape: BoxShape.circle,
511
- border: Border.all(
512
- color: colors.onSurface.withValues(alpha: 0.14),
513
- width: 1.5,
514
- ),
515
- boxShadow: <BoxShadow>[
516
- BoxShadow(
517
- color: colors.onSurface.withValues(alpha: dragging ? 0.20 : 0.08),
518
- blurRadius: dragging ? 18 : 10,
519
- offset: Offset(0, dragging ? 6 : 3),
520
- ),
521
- ],
522
- ),
523
- child: busy
524
- ? SizedBox(
525
- width: 20,
526
- height: 20,
527
- child: CircularProgressIndicator(
528
- strokeWidth: 2,
529
- valueColor: AlwaysStoppedAnimation<Color>(colors.primary),
530
- ),
531
- )
532
- : Icon(
533
- active ? KasyIcons.copy : KasyIcons.widgets,
534
- color: colors.primary,
535
- size: 22,
536
- ),
537
- ),
538
- );
539
- }
540
- }
541
-
542
656
  class _HighlightPainter extends CustomPainter {
543
657
  _HighlightPainter({required this.rect});
544
658
 
@@ -564,3 +678,96 @@ class _HighlightPainter extends CustomPainter {
564
678
  bool shouldRepaint(_HighlightPainter oldDelegate) =>
565
679
  oldDelegate.rect != rect;
566
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
+ }