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
@@ -8,89 +8,128 @@ class DevInspectorService {
8
8
 
9
9
  static const _screenRegex = r'(Page|Screen|Route|View)$';
10
10
 
11
+ // Widgets we refuse to return — even after climbing.
11
12
  static const _skipSet = <String>{
12
13
  'RenderView',
13
- 'RepaintBoundary',
14
- 'PhysicalModel',
15
- 'Semantics',
16
- 'BlockSemantics',
17
- 'ExcludeSemantics',
18
- 'MergeSemantics',
19
- 'Focus',
20
- 'FocusScope',
21
- 'FocusTraversalOrder',
22
- 'FocusTraversalGroup',
23
- 'Actions',
24
- 'Shortcuts',
25
- 'MouseRegion',
26
- 'Listener',
27
- 'AnnotatedRegion',
28
- 'Localizations',
29
- 'Directionality',
30
- 'Title',
31
- 'MediaQuery',
32
- 'DefaultTextStyle',
33
- 'IconTheme',
34
- 'NotificationListener',
35
- 'ScrollConfiguration',
36
- 'PrimaryScrollController',
37
- 'ScrollNotificationObserver',
38
- 'HeroControllerScope',
39
14
  'WidgetInspector',
40
15
  'CheckedModeBanner',
41
16
  'Banner',
42
17
  'DevInspector',
43
18
  };
44
19
 
45
- // Flutter internal layout widgets walk up to a semantic ancestor
46
- static const _preferParentSet = <String>{
47
- 'RichText',
48
- 'RawGestureDetector',
49
- 'RawMaterialButton',
50
- 'Center',
51
- 'SizedBox',
52
- };
20
+ // Widgets we climb past while looking for a meaningful target. These are
21
+ // framework-level wrappers (layout primitives, decorations, animations,
22
+ // scroll machinery, theming, etc.) — selecting them is rarely useful.
23
+ static const _climbPastSet = <String>{
24
+ // Repaint / physical
25
+ 'RepaintBoundary', 'PhysicalModel', 'PhysicalShape', 'CustomPaint',
26
+
27
+ // Layout primitives
28
+ 'Padding', 'Container', 'SizedBox', 'Center', 'Align',
29
+ 'Expanded', 'Flexible', 'Spacer',
30
+ 'ConstrainedBox', 'UnconstrainedBox', 'FractionallySizedBox',
31
+ 'AspectRatio', 'LimitedBox', 'OverflowBox', 'FittedBox', 'Baseline',
32
+ 'IntrinsicWidth', 'IntrinsicHeight',
33
+ 'DecoratedBox', 'ColoredBox', 'DecoratedBoxTransition',
34
+
35
+ // Multi-child layout
36
+ 'Column', 'Row', 'Stack', 'Wrap', 'Flow', 'IndexedStack',
37
+ 'Positioned', 'PositionedDirectional',
38
+
39
+ // Material / Ink
40
+ 'Material', 'Ink', 'InkWell', 'InkResponse',
41
+
42
+ // Pointer-related
43
+ 'GestureDetector', 'RawGestureDetector', 'MouseRegion',
44
+ 'Listener', 'AbsorbPointer', 'IgnorePointer', 'PointerInterceptor',
45
+
46
+ // Builders
47
+ 'Builder', 'LayoutBuilder', 'StreamBuilder', 'FutureBuilder',
48
+ 'ValueListenableBuilder', 'ListenableBuilder',
49
+ 'OrientationBuilder', 'MediaQuery',
50
+
51
+ // Animations
52
+ 'AnimatedContainer', 'AnimatedOpacity', 'AnimatedSize',
53
+ 'AnimatedSwitcher', 'AnimatedDefaultTextStyle',
54
+ 'AnimatedPhysicalModel', 'AnimatedBuilder', 'AnimatedAlign',
55
+ 'AnimatedPositioned', 'AnimatedPadding', 'AnimatedTheme',
56
+ 'FadeTransition', 'SlideTransition', 'ScaleTransition',
57
+ 'RotationTransition', 'SizeTransition',
58
+ 'PositionedTransition', 'RelativePositionedTransition',
59
+ 'AlignTransition', 'DefaultTextStyleTransition',
53
60
 
54
- static const _genericUiSet = <String>{
55
- 'Padding',
56
- 'Container',
57
- 'Center',
58
- 'Align',
59
- 'GestureDetector',
60
- 'RawGestureDetector',
61
- 'InkWell',
62
- 'Material',
63
- 'DefaultSelectionStyle',
64
- 'Column',
65
- 'Row',
66
- 'SizedBox',
67
- 'Expanded',
68
- 'Flexible',
69
- 'Ink',
70
- 'Builder',
71
- 'ClipRect',
72
- 'ClipRRect',
73
- 'SliverToBoxAdapter',
74
- 'SliverPadding',
75
- 'Viewport',
76
- 'CustomPaint',
77
- 'ImageFiltered',
78
- 'AnimatedBuilder',
61
+ // Transforms / effects
62
+ 'Transform', 'Opacity', 'BackdropFilter', 'ImageFiltered',
63
+ 'ClipRect', 'ClipRRect', 'ClipOval', 'ClipPath',
64
+
65
+ // Context propagation
66
+ 'Theme', 'DefaultTextStyle', 'IconTheme', 'IconButtonTheme',
67
+ 'Localizations', 'Directionality', 'Title',
68
+ 'DefaultSelectionStyle', 'SelectionContainer', 'AnnotatedRegion',
69
+
70
+ // Routing / storage
71
+ 'KeyedSubtree', 'AutomaticKeepAlive', 'KeepAlive', 'TickerMode',
72
+ 'Offstage', 'PageStorage', 'RestorationScope',
73
+ 'UnmanagedRestorationScope', 'HeroControllerScope', 'HeroMode',
74
+ 'PopScope', 'WillPopScope', 'BackButtonListener', 'Visibility',
75
+ 'NavigatorPopHandler',
76
+
77
+ // Scroll / slivers
78
+ 'NotificationListener', 'SafeArea',
79
+ 'SliverPadding', 'SliverToBoxAdapter', 'SliverList', 'SliverGrid',
80
+ 'SliverFillRemaining', 'SliverFillViewport', 'SliverFixedExtentList',
81
+ 'SliverPrototypeExtentList', 'SliverMainAxisGroup', 'SliverCrossAxisGroup',
82
+ 'CustomScrollView', 'NestedScrollView', 'SingleChildScrollView',
83
+ 'PrimaryScrollController', 'ScrollConfiguration', 'Scrollable',
84
+ 'ScrollNotificationObserver', 'Viewport', 'ShrinkWrappingViewport',
85
+
86
+ // Focus / semantics / actions
87
+ 'Focus', 'FocusScope', 'FocusTraversalOrder', 'FocusTraversalGroup',
88
+ 'Actions', 'Shortcuts', 'CallbackShortcuts',
89
+ 'Semantics', 'BlockSemantics', 'ExcludeSemantics', 'MergeSemantics',
79
90
  'IndexedSemantics',
80
- 'AnimatedPhysicalModel',
81
- 'AnimatedDefaultTextStyle',
91
+
92
+ // Atomic-but-noisy
93
+ 'RichText', 'RawMaterialButton',
82
94
  };
83
95
 
84
- static bool _isGenericUi(String typeName) {
85
- if (_genericUiSet.contains(typeName)) return true;
96
+ static bool _shouldClimbPast(String typeName) {
97
+ if (_climbPastSet.contains(typeName)) return true;
86
98
  return typeName.startsWith('NotificationListener<') ||
87
99
  typeName.startsWith('Listener<') ||
88
100
  typeName.startsWith('Builder<');
89
101
  }
90
102
 
103
+ // Used by ancestor-list builders (kept as-is — affects displayed tree only).
104
+ static bool _isGenericUi(String typeName) => _shouldClimbPast(typeName);
105
+
106
+ /// Private widgets we DO want to skip — usually framework internals that
107
+ /// add noise (focus scopes, restoration scopes, builder wrappers, …).
108
+ /// Anything else starting with `_` is treated as a user-defined private
109
+ /// widget (e.g. `_HomeCard`, `_NavItem`) and is fully selectable.
110
+ static const _privateFrameworkSet = <String>{
111
+ '_FocusInheritedScope', '_FocusScopeMarker', '_FocusMarker',
112
+ '_InheritedTheme', '_InheritedAnimatedTheme',
113
+ '_RestorationScope', '_UnmanagedRestorationScope',
114
+ '_NestedHero', '_HeroFlightManifest',
115
+ '_BodyBuilder', '_ScaffoldSlot',
116
+ '_ActionsScope', '_ShortcutsMarker',
117
+ '_LocalizationsScope', '_DirectionalityScope',
118
+ '_MediaQueryFromView', '_MediaQueryFromWindow',
119
+ '_OverlayEntryWidget', '_OverlayState',
120
+ '_ModalScope', '_ModalScopeStatus', '_RouteEntry',
121
+ '_BannerWidget', '_CheckedModeBanner',
122
+ '_TickerModeMarker',
123
+ '_Pending', '_LinearProgressIndicator', '_CircularProgressIndicator',
124
+ '_ScrollNotificationObserverScope',
125
+ '_PrimaryScrollControllerScope',
126
+ '_TapRegionRegistry', '_TapRegionSurface',
127
+ '_KeyedSubtree',
128
+ };
129
+
91
130
  static bool _shouldSkip(String typeName) {
92
- if (typeName.startsWith('_')) return true;
93
- return _skipSet.contains(typeName);
131
+ if (_skipSet.contains(typeName)) return true;
132
+ return _privateFrameworkSet.contains(typeName);
94
133
  }
95
134
 
96
135
  static List<String> _extractProperties(Element element) {
@@ -163,16 +202,14 @@ class DevInspectorService {
163
202
  // 1) Prioritize feature-level widgets/classes first.
164
203
  for (final type in chain) {
165
204
  if (_shouldSkip(type)) continue;
166
- if (_preferParentSet.contains(type)) continue;
167
- if (_isGenericUi(type)) continue;
205
+ if (_shouldClimbPast(type)) continue;
168
206
  if (featureRegex.hasMatch(type)) return type;
169
207
  }
170
208
 
171
209
  // 2) Fallback to first non-generic non-skipped widget.
172
210
  for (final type in chain) {
173
211
  if (_shouldSkip(type)) continue;
174
- if (_preferParentSet.contains(type)) continue;
175
- if (_isGenericUi(type)) continue;
212
+ if (_shouldClimbPast(type)) continue;
176
213
  return type;
177
214
  }
178
215
  return null;
@@ -242,16 +279,24 @@ class DevInspectorService {
242
279
  var typeName = element.widget.runtimeType.toString();
243
280
  var activeElement = element;
244
281
 
245
- if (_preferParentSet.contains(typeName)) {
282
+ // Climb past framework wrappers (layout, decoration, animation, scroll,
283
+ // theming, etc.) to reach the most meaningful widget — typically a custom
284
+ // widget from the user's project or a semantic widget (Text, Icon, Card,
285
+ // Button, …).
286
+ if (_shouldClimbPast(typeName) || _shouldSkip(typeName)) {
287
+ Element? found;
288
+ String? foundName;
246
289
  element.visitAncestorElements((ancestor) {
247
290
  final name = ancestor.widget.runtimeType.toString();
248
- if (!_shouldSkip(name) && !_preferParentSet.contains(name)) {
249
- typeName = name;
250
- activeElement = ancestor;
251
- return false;
252
- }
253
- return true;
291
+ if (_shouldSkip(name)) return true;
292
+ if (_shouldClimbPast(name)) return true;
293
+ found = ancestor;
294
+ foundName = name;
295
+ return false;
254
296
  });
297
+ if (found == null || foundName == null) return null;
298
+ typeName = foundName!;
299
+ activeElement = found!;
255
300
  }
256
301
 
257
302
  if (_shouldSkip(typeName)) return null;
@@ -280,18 +325,20 @@ class DevInspectorService {
280
325
  );
281
326
  }
282
327
 
283
- /// Hit-tests at [globalPosition] and returns the inspector info plus the
284
- /// underlying render object so the caller can re-measure the bounds during
285
- /// animations.
286
- static ({DevInspectorInfo info, RenderObject renderObject})? pickAt(
328
+ /// Hit-tests inside [contentBox] at [globalPosition] and returns the
329
+ /// inspector info plus the underlying render object. Starting the hit test
330
+ /// from the content box (instead of the root render view) skips the
331
+ /// DevInspector's own overlay subtree.
332
+ static ({DevInspectorInfo info, RenderObject renderObject})? pickAtInBox(
333
+ RenderBox contentBox,
287
334
  Offset globalPosition,
288
335
  ) {
289
336
  if (!kDebugMode) return null;
337
+ if (!contentBox.attached) return null;
290
338
  try {
339
+ final Offset local = contentBox.globalToLocal(globalPosition);
291
340
  final result = BoxHitTestResult();
292
- final renderView = WidgetsBinding.instance.renderViews.firstOrNull;
293
- if (renderView == null) return null;
294
- renderView.hitTest(result, position: globalPosition);
341
+ contentBox.hitTest(result, position: local);
295
342
  for (final entry in result.path) {
296
343
  if (entry.target is! RenderObject) continue;
297
344
  final RenderObject candidate = entry.target as RenderObject;
@@ -312,4 +359,72 @@ class DevInspectorService {
312
359
  if (!target.attached) return null;
313
360
  return _globalBoundingRect(target);
314
361
  }
362
+
363
+ /// Climbs one level up the meaningful-widget hierarchy from [current].
364
+ /// Used to bubble a repeat-tap up to the parent widget when the current
365
+ /// selection isn't the one the user wanted.
366
+ static ({DevInspectorInfo info, RenderObject renderObject})? climbFrom(
367
+ RenderObject current,
368
+ ) {
369
+ if (!kDebugMode) return null;
370
+ if (!current.attached) return null;
371
+ try {
372
+ final creator = current.debugCreator;
373
+ if (creator == null) return null;
374
+ Element? element;
375
+ try {
376
+ // ignore: avoid_dynamic_calls
377
+ element = (creator as dynamic).element as Element?;
378
+ } catch (_) {
379
+ return null;
380
+ }
381
+ if (element == null) return null;
382
+
383
+ Element? found;
384
+ element.visitAncestorElements((ancestor) {
385
+ final name = ancestor.widget.runtimeType.toString();
386
+ if (_shouldSkip(name)) return true;
387
+ if (_shouldClimbPast(name)) return true;
388
+ found = ancestor;
389
+ return false;
390
+ });
391
+ if (found == null) return null;
392
+ final RenderObject? ro = found!.findRenderObject();
393
+ if (ro == null || !ro.attached) return null;
394
+ final info = _buildInfoForElement(found!, ro);
395
+ return (info: info, renderObject: ro);
396
+ } catch (_) {
397
+ return null;
398
+ }
399
+ }
400
+
401
+ /// Builds info from an element/render-object pair without re-running the
402
+ /// climb logic. Used by [climbFrom] (which already picked the element).
403
+ static DevInspectorInfo _buildInfoForElement(
404
+ Element element,
405
+ RenderObject ro,
406
+ ) {
407
+ final typeName = element.widget.runtimeType.toString();
408
+ final Rect rect = _globalBoundingRect(ro);
409
+ final ancestors = _extractAncestors(element);
410
+ final properties = _extractProperties(element);
411
+ final screenHint = _extractScreenHint(element, ancestors);
412
+ final semanticWidget = _extractSemanticWidget(typeName, ancestors);
413
+ final searchHints = _extractSearchHints(
414
+ widgetType: typeName,
415
+ properties: properties,
416
+ ancestors: ancestors,
417
+ screenHint: screenHint,
418
+ semanticWidget: semanticWidget,
419
+ );
420
+ return DevInspectorInfo(
421
+ widgetType: typeName,
422
+ boundingBox: rect,
423
+ properties: properties,
424
+ ancestors: ancestors,
425
+ screenHint: screenHint,
426
+ semanticWidget: semanticWidget,
427
+ searchHints: searchHints,
428
+ );
429
+ }
315
430
  }
@@ -50,6 +50,7 @@ abstract final class KasyIcons {
50
50
  static const IconData download = LucideIcons.download300;
51
51
  static const IconData linkOutlined = LucideIcons.link300;
52
52
  static const IconData message = LucideIcons.messageSquare300;
53
+ static const IconData monitor = LucideIcons.monitor300;
53
54
  static const IconData moreVert = LucideIcons.ellipsisVertical300;
54
55
  static const IconData microphone = LucideIcons.mic300;
55
56
  static const IconData northEast = LucideIcons.arrowUpRight300;
@@ -109,7 +109,9 @@ class KasyColors extends ThemeExtension<KasyColors> {
109
109
  factory KasyColors.light() => const KasyColors(
110
110
  primary: Color(0xFF1E88E5),
111
111
  onPrimary: Color(0xFFFFFFFF),
112
- // Canvas: slightly lighter than systemGroupedBackground for a softer wash; surfaces stay white.
112
+ // Also used by the native splash. When changing, update
113
+ // `flutter_native_splash.color` in pubspec.yaml and run
114
+ // `dart run flutter_native_splash:create`.
113
115
  background: Color(0xFFF7F7F7),
114
116
  onBackground: Color(0xFF000000),
115
117
  surface: Color(0xFFFFFFFF),
@@ -138,7 +140,9 @@ class KasyColors extends ThemeExtension<KasyColors> {
138
140
  factory KasyColors.dark() => const KasyColors(
139
141
  primary: Color(0xFF1E88E5),
140
142
  onPrimary: Color(0xFFFFFFFF),
141
- // Canvas: Material dark baseline; surfaces: iOS-style elevated grey (cards / chrome).
143
+ // Also used by the native splash. When changing, update
144
+ // `flutter_native_splash.color_dark` in pubspec.yaml and run
145
+ // `dart run flutter_native_splash:create`.
142
146
  background: Color(0xFF060608),
143
147
  onBackground: Color(0xFFFAFAFA),
144
148
  surface: Color(0xFF18181B),
@@ -5,9 +5,9 @@ import 'package:device_preview/device_preview.dart';
5
5
  import 'package:flutter/foundation.dart';
6
6
  import 'package:flutter/material.dart';
7
7
  import 'package:flutter/rendering.dart';
8
+ import 'package:flutter/services.dart';
8
9
  import 'package:jiffy/jiffy.dart';
9
10
  import 'package:kasy_kit/core/dev_inspector/dev_inspector.dart';
10
- import 'package:kasy_kit/core/theme/theme.dart';
11
11
  import 'package:kasy_kit/i18n/translations.g.dart';
12
12
  import 'package:provider/provider.dart';
13
13
  import 'package:shared_preferences/shared_preferences.dart';
@@ -25,6 +25,16 @@ const String _textScalePrefKey = 'web_device_preview_text_scale';
25
25
  final ValueNotifier<bool> webDevicePreviewEnabledNotifier =
26
26
  ValueNotifier<bool>(false);
27
27
 
28
+ /// Keyboard shortcut for toggling the web device preview chrome, formatted
29
+ /// for the current platform. Key names stay in English regardless of the
30
+ /// app locale — universal keyboard conventions.
31
+ String webDevicePreviewShortcutLabel() {
32
+ final String mod = defaultTargetPlatform == TargetPlatform.macOS
33
+ ? 'Command'
34
+ : 'Ctrl';
35
+ return '$mod + Shift + D';
36
+ }
37
+
28
38
  // Platform order: 0 = iOS, 1 = Android, 2 = iPad
29
39
 
30
40
  // iOS: compact → standard → pro
@@ -76,7 +86,8 @@ class WebDevicePreview extends StatefulWidget {
76
86
  State<WebDevicePreview> createState() => _WebDevicePreviewState();
77
87
  }
78
88
 
79
- class _WebDevicePreviewState extends State<WebDevicePreview> {
89
+ class _WebDevicePreviewState extends State<WebDevicePreview>
90
+ with WidgetsBindingObserver {
80
91
  int _platform = 0; // 0 = iOS, 1 = Android, 2 = iPad
81
92
  int _iosIndex = 1; // default: iPhone 16
82
93
  int _androidIndex = 1; // default: Google Pixel 9
@@ -112,16 +123,52 @@ class _WebDevicePreviewState extends State<WebDevicePreview> {
112
123
  @override
113
124
  void initState() {
114
125
  super.initState();
126
+ WidgetsBinding.instance.addObserver(this);
115
127
  _localeNotifier = ValueNotifier<AppLocale>(
116
128
  LocaleSettings.instance.currentLocale,
117
129
  );
118
130
  webDevicePreviewEnabledNotifier.addListener(_onEnabledChanged);
119
131
  _bgDarkNotifier.addListener(_onBgChanged);
132
+ HardwareKeyboard.instance.addHandler(_handleKeyEvent);
120
133
  unawaited(_bootstrap());
121
134
  }
122
135
 
136
+ @override
137
+ void didChangePlatformBrightness() {
138
+ super.didChangePlatformBrightness();
139
+ // The simulated MediaQuery below forwards the host browser's brightness;
140
+ // rebuild so the app inside the device frame follows OS dark/light when
141
+ // the user's theme is set to "system".
142
+ if (mounted) setState(() {});
143
+ }
144
+
145
+ /// Global toggle for the device preview chrome.
146
+ ///
147
+ /// `Cmd/Ctrl + Shift + D` — works from anywhere (debug only). Mirrors what
148
+ /// the close button does, persisting the choice to prefs so subsequent
149
+ /// launches respect it.
150
+ bool _handleKeyEvent(KeyEvent event) {
151
+ if (!kDebugMode) return false;
152
+ if (event is! KeyDownEvent) return false;
153
+ if (event.logicalKey != LogicalKeyboardKey.keyD) return false;
154
+ final HardwareKeyboard kb = HardwareKeyboard.instance;
155
+ if (!kb.isShiftPressed) return false;
156
+ if (!(kb.isMetaPressed || kb.isControlPressed)) return false;
157
+ if (kb.isAltPressed) return false;
158
+ final bool turningOn = !webDevicePreviewEnabledNotifier.value;
159
+ webDevicePreviewEnabledNotifier.value = turningOn;
160
+ unawaited(() async {
161
+ final prefs = await SharedPreferences.getInstance();
162
+ await prefs.setBool(webDevicePreviewEnabledPrefKey, turningOn);
163
+ }());
164
+ HapticFeedback.lightImpact();
165
+ return true;
166
+ }
167
+
123
168
  @override
124
169
  void dispose() {
170
+ WidgetsBinding.instance.removeObserver(this);
171
+ HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
125
172
  webDevicePreviewEnabledNotifier.removeListener(_onEnabledChanged);
126
173
  _bgDarkNotifier.removeListener(_onBgChanged);
127
174
  _deviceNotifier.dispose();
@@ -135,6 +182,10 @@ class _WebDevicePreviewState extends State<WebDevicePreview> {
135
182
  }
136
183
 
137
184
  void _onEnabledChanged() {
185
+ // Our chrome already surfaces inspector state via the pill, so suppress
186
+ // the DevInspector's in-app status pill while the preview is on.
187
+ devInspectorSuppressStatusPillNotifier.value =
188
+ webDevicePreviewEnabledNotifier.value;
138
189
  if (webDevicePreviewEnabledNotifier.value) {
139
190
  _controlsTimer?.cancel();
140
191
  _controlsTimer = Timer(const Duration(milliseconds: 800), () {
@@ -152,7 +203,10 @@ class _WebDevicePreviewState extends State<WebDevicePreview> {
152
203
  Future<void> _bootstrap() async {
153
204
  final prefs = await SharedPreferences.getInstance();
154
205
 
155
- final savedEnabled = prefs.getBool(webDevicePreviewEnabledPrefKey) ?? false;
206
+ // Default ON for the first run: most devs hitting `kasy run --web` want to
207
+ // preview their mobile app inside a device frame. Closing the chrome
208
+ // persists the choice, so subsequent launches respect what the user left.
209
+ final savedEnabled = prefs.getBool(webDevicePreviewEnabledPrefKey) ?? true;
156
210
  final savedPlatform = prefs.getInt(_platformPrefKey);
157
211
  final savedIosIndex = prefs.getInt(_iosIndexPrefKey);
158
212
  final savedAndroidIndex = prefs.getInt(_androidIndexPrefKey);
@@ -327,8 +381,6 @@ class _WebDevicePreviewState extends State<WebDevicePreview> {
327
381
  @override
328
382
  Widget build(BuildContext context) {
329
383
  final enabled = webDevicePreviewEnabledNotifier.value;
330
- // Depend on ThemeProvider so the pill icon updates when app theme changes.
331
- final appIsDark = ThemeProvider.of(context).mode == ThemeMode.dark;
332
384
 
333
385
  // ThemeData.light() in Flutter 3.x M3 uses a lavender-tinted surface.
334
386
  // Passing backgroundColor directly avoids that and gives us an exact color.
@@ -347,6 +399,11 @@ class _WebDevicePreviewState extends State<WebDevicePreview> {
347
399
  builder: (ctx, _) => MediaQuery(
348
400
  data: MediaQuery.of(ctx).copyWith(
349
401
  textScaler: TextScaler.linear(_textScaleNotifier.value),
402
+ // DevicePreview hard-codes platformBrightness to light inside the
403
+ // simulated frame, which breaks MaterialApp's ThemeMode.system.
404
+ // Forward the real host brightness so "system" tracks the OS.
405
+ platformBrightness: WidgetsBinding
406
+ .instance.platformDispatcher.platformBrightness,
350
407
  ),
351
408
  child: _DeviceSwitchBridge(
352
409
  deviceNotifier: _deviceNotifier,
@@ -367,6 +424,14 @@ class _WebDevicePreviewState extends State<WebDevicePreview> {
367
424
  fit: StackFit.expand,
368
425
  children: [
369
426
  RepaintBoundary(key: _screenshotKey, child: preview),
427
+ // Inspector highlight drawn ABOVE the device frame so widgets that
428
+ // hug the edges of the simulated viewport (AppBar, bottom nav, …)
429
+ // still get a complete border instead of being clipped by the chrome.
430
+ const Positioned.fill(
431
+ child: IgnorePointer(
432
+ child: _DevInspectorExternalHighlight(),
433
+ ),
434
+ ),
370
435
  Positioned(
371
436
  top: 24,
372
437
  right: 24,
@@ -389,7 +454,6 @@ class _WebDevicePreviewState extends State<WebDevicePreview> {
389
454
  deviceName: _shortName(_currentDevice.name),
390
455
  frameVisible: _frameVisibleNotifier.value,
391
456
  bgDark: _bgDarkNotifier.value,
392
- appIsDark: appIsDark,
393
457
  isLandscape: _landscapeNotifier.value,
394
458
  textScale: _textScaleNotifier.value,
395
459
  currentLocale: _localeNotifier.value,
@@ -399,7 +463,6 @@ class _WebDevicePreviewState extends State<WebDevicePreview> {
399
463
  onNext: _next,
400
464
  onToggleFrame: _toggleFrame,
401
465
  onToggleBg: _toggleBg,
402
- onToggleAppTheme: () => ThemeProvider.of(context).toggle(),
403
466
  onToggleLandscape: _toggleLandscape,
404
467
  onCycleTextScale: _cycleTextScale,
405
468
  onCycleLocale: _cycleLocale,
@@ -498,7 +561,6 @@ class _PreviewControls extends StatefulWidget {
498
561
  required this.deviceName,
499
562
  required this.frameVisible,
500
563
  required this.bgDark,
501
- required this.appIsDark,
502
564
  required this.isLandscape,
503
565
  required this.textScale,
504
566
  required this.currentLocale,
@@ -508,7 +570,6 @@ class _PreviewControls extends StatefulWidget {
508
570
  required this.onNext,
509
571
  required this.onToggleFrame,
510
572
  required this.onToggleBg,
511
- required this.onToggleAppTheme,
512
573
  required this.onToggleLandscape,
513
574
  required this.onCycleTextScale,
514
575
  required this.onCycleLocale,
@@ -522,7 +583,6 @@ class _PreviewControls extends StatefulWidget {
522
583
  final String deviceName;
523
584
  final bool frameVisible;
524
585
  final bool bgDark;
525
- final bool appIsDark;
526
586
  final bool isLandscape;
527
587
  final double textScale;
528
588
  final AppLocale currentLocale;
@@ -532,7 +592,6 @@ class _PreviewControls extends StatefulWidget {
532
592
  final VoidCallback onNext;
533
593
  final VoidCallback onToggleFrame;
534
594
  final VoidCallback onToggleBg;
535
- final VoidCallback onToggleAppTheme;
536
595
  final VoidCallback onToggleLandscape;
537
596
  final VoidCallback onCycleTextScale;
538
597
  final VoidCallback onCycleLocale;
@@ -895,14 +954,6 @@ class _PreviewControlsState extends State<_PreviewControls> {
895
954
  checked: widget.bgDark,
896
955
  onTap: widget.onToggleBg,
897
956
  ),
898
- _ToolsItem(
899
- icon: widget.appIsDark
900
- ? Icons.dark_mode_rounded
901
- : Icons.light_mode_rounded,
902
- label: t.webDevicePreview.darkTheme,
903
- checked: widget.appIsDark,
904
- onTap: widget.onToggleAppTheme,
905
- ),
906
957
  _ToolsItem(
907
958
  icon: widget.isLandscape
908
959
  ? Icons.stay_current_landscape_rounded
@@ -1158,3 +1209,52 @@ class _IconBtnState extends State<_IconBtn> {
1158
1209
  );
1159
1210
  }
1160
1211
  }
1212
+
1213
+ // ---------------------------------------------------------------------------
1214
+ // External inspector highlight — draws the DevInspector selection rect ABOVE
1215
+ // the simulated device frame so widgets that hug the viewport edges keep a
1216
+ // complete, uncropped border. Listens to the global rect notifier published
1217
+ // by DevInspector.
1218
+ // ---------------------------------------------------------------------------
1219
+
1220
+ class _DevInspectorExternalHighlight extends StatelessWidget {
1221
+ const _DevInspectorExternalHighlight();
1222
+
1223
+ @override
1224
+ Widget build(BuildContext context) {
1225
+ return ValueListenableBuilder<Rect?>(
1226
+ valueListenable: devInspectorHighlightGlobalRect,
1227
+ builder: (BuildContext context, Rect? rect, Widget? _) {
1228
+ if (rect == null || rect.isEmpty) return const SizedBox.shrink();
1229
+ return CustomPaint(
1230
+ painter: _ExternalHighlightPainter(rect: rect),
1231
+ size: Size.infinite,
1232
+ );
1233
+ },
1234
+ );
1235
+ }
1236
+ }
1237
+
1238
+ class _ExternalHighlightPainter extends CustomPainter {
1239
+ _ExternalHighlightPainter({required this.rect});
1240
+
1241
+ final Rect rect;
1242
+
1243
+ @override
1244
+ void paint(Canvas canvas, Size size) {
1245
+ if (rect.isEmpty) return;
1246
+ final Paint paint = Paint()
1247
+ ..color = devInspectorHighlightColor
1248
+ ..style = PaintingStyle.stroke
1249
+ ..strokeWidth = devInspectorHighlightStrokeWidth;
1250
+ final Rect outer = rect.inflate(devInspectorHighlightStrokeWidth / 2);
1251
+ final RRect rrect = RRect.fromRectAndRadius(
1252
+ outer,
1253
+ const Radius.circular(devInspectorHighlightCornerRadius),
1254
+ );
1255
+ canvas.drawRRect(rrect, paint);
1256
+ }
1257
+
1258
+ @override
1259
+ bool shouldRepaint(_ExternalHighlightPainter old) => old.rect != rect;
1260
+ }