kasy-cli 1.37.1 → 1.39.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 (120) hide show
  1. package/lib/scaffold/CHANGELOG.json +23 -0
  2. package/lib/scaffold/backends/api/patch/README.md +15 -0
  3. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  4. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
  5. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  6. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  7. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  8. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  10. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  11. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  12. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  13. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  15. package/lib/scaffold/shared/generator-utils.js +12 -6
  16. package/package.json +1 -1
  17. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  18. package/templates/firebase/AGENTS.md +7 -1
  19. package/templates/firebase/DESIGN_SYSTEM.md +35 -8
  20. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  21. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  22. package/templates/firebase/assets/icons/facebook.svg +49 -0
  23. package/templates/firebase/assets/icons/google.svg +1 -0
  24. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  25. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  26. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  27. package/templates/firebase/lib/components/components.dart +1 -1
  28. package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
  29. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  30. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  32. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
  34. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  35. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  36. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
  37. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  38. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
  39. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  40. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  41. package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
  42. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  43. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  44. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  45. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  46. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  47. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
  48. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  49. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  50. package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
  51. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  52. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  53. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  54. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  55. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  56. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  57. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  58. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
  59. package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
  60. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  61. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  62. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  63. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  66. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  67. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  68. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  69. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  70. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  71. package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
  72. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
  73. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  74. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  75. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  76. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
  77. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  78. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
  79. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  80. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  81. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  82. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  83. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  84. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  85. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  86. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  87. package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
  88. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
  89. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  90. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  91. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  92. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
  93. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
  94. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  95. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
  96. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  97. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  98. package/templates/firebase/lib/i18n/en.i18n.json +749 -698
  99. package/templates/firebase/lib/i18n/es.i18n.json +749 -698
  100. package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
  101. package/templates/firebase/lib/main.dart +20 -7
  102. package/templates/firebase/lib/router.dart +70 -46
  103. package/templates/firebase/pubspec.yaml +2 -1
  104. package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
  105. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  106. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  107. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  108. package/templates/firebase/tool/design_check.dart +9 -0
  109. package/templates/firebase/assets/icons/apple.png +0 -0
  110. package/templates/firebase/assets/icons/facebook.png +0 -0
  111. package/templates/firebase/assets/icons/google.png +0 -0
  112. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  113. package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
  114. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  115. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  116. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  117. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  118. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
  119. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
  120. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -6,12 +6,15 @@ import 'package:flutter/foundation.dart';
6
6
  import 'package:flutter/material.dart';
7
7
  import 'package:flutter/rendering.dart';
8
8
  import 'package:flutter/services.dart';
9
+ import 'package:google_fonts/google_fonts.dart';
9
10
  import 'package:jiffy/jiffy.dart';
10
11
  import 'package:kasy_kit/core/dev_inspector/dev_inspector.dart';
12
+ import 'package:kasy_kit/core/icons/kasy_icons.dart';
13
+ import 'package:kasy_kit/core/theme/providers/theme_provider.dart';
14
+ import 'package:kasy_kit/core/web_device_preview/png_clipboard.dart';
11
15
  import 'package:kasy_kit/i18n/translations.g.dart';
12
16
  import 'package:provider/provider.dart';
13
17
  import 'package:shared_preferences/shared_preferences.dart';
14
- import 'package:universal_html/html.dart' as html;
15
18
 
16
19
  // Suffixed `_v2` because the default flipped from OFF to ON. Values saved under
17
20
  // the old key were written while the default was OFF, so we ignore them and
@@ -25,8 +28,43 @@ const String _bgDarkPrefKey = 'web_device_preview_bg_dark';
25
28
  const String _landscapePrefKey = 'web_device_preview_landscape';
26
29
  const String _textScalePrefKey = 'web_device_preview_text_scale';
27
30
 
28
- final ValueNotifier<bool> webDevicePreviewEnabledNotifier =
29
- ValueNotifier<bool>(false);
31
+ final ValueNotifier<bool> webDevicePreviewEnabledNotifier = ValueNotifier<bool>(
32
+ false,
33
+ );
34
+
35
+ /// True only once the device frame is actually ON SCREEN (not the instant the
36
+ /// toggle flips — the frame takes a moment to build). The web viewport scale
37
+ /// reads THIS, not [webDevicePreviewEnabledNotifier], so the scale drops to
38
+ /// native 1.0 only when the frame is up — otherwise the toggle would flash a
39
+ /// big, unframed, unscaled app while the frame builds.
40
+ final ValueNotifier<bool> webDevicePreviewActiveNotifier = ValueNotifier<bool>(
41
+ false,
42
+ );
43
+
44
+ // Kit primary/accent (HeroUI blue). The chrome lives above the MaterialApp, so
45
+ // it can't read context.colors — mirror the design-system value here. Used to
46
+ // highlight active toggles so it's obvious at a glance what's turned on.
47
+ const Color _kAccent = Color(0xFF0485F7);
48
+
49
+ // ROADMAP: secondary controls (locale, landscape orientation, text-scale) are
50
+ // hidden for now — they'll move to a dedicated vertical toolbar later, while the
51
+ // main horizontal pill stays for quick access. Return true to bring them back
52
+ // inline. (A function, not a const, so the guarded widgets aren't dead code.)
53
+ bool _showSecondaryTools() => false;
54
+
55
+ // Inter — the kit's typeface. Centralizes the chrome's text styling so every
56
+ // label shares the design-system font.
57
+ TextStyle _chromeText({
58
+ required double size,
59
+ required FontWeight weight,
60
+ required Color color,
61
+ double spacing = 0,
62
+ }) => GoogleFonts.inter(
63
+ fontSize: size,
64
+ fontWeight: weight,
65
+ color: color,
66
+ letterSpacing: spacing,
67
+ );
30
68
 
31
69
  /// Keyboard shortcut for toggling the web device preview chrome, formatted
32
70
  /// for the current platform. Key names stay in English regardless of the
@@ -61,20 +99,6 @@ final List<DeviceInfo> _iPadDevices = [
61
99
  Devices.ios.iPadPro13InchesM4,
62
100
  ];
63
101
 
64
- // Compact labels so the pill stays narrow regardless of how many devices we add.
65
- String _shortName(String name) => switch (name) {
66
- 'iPhone SE' => 'SE',
67
- 'iPhone 16' => '16',
68
- 'iPhone 16 Pro' => '16 Pro',
69
- 'iPad' => 'iPad',
70
- 'iPad Pro 11" (M4)' => '11"',
71
- 'iPad Pro 13" (M4)' => '13"',
72
- 'Samsung Galaxy A50' => 'A50',
73
- 'Google Pixel 9' => 'Pixel 9',
74
- 'Samsung Galaxy S25' => 'S25',
75
- _ => name,
76
- };
77
-
78
102
  class WebDevicePreview extends StatefulWidget {
79
103
  const WebDevicePreview({super.key, required this.child});
80
104
 
@@ -92,14 +116,18 @@ class WebDevicePreview extends StatefulWidget {
92
116
  class _WebDevicePreviewState extends State<WebDevicePreview>
93
117
  with WidgetsBindingObserver {
94
118
  int _platform = 0; // 0 = iOS, 1 = Android, 2 = iPad
95
- int _iosIndex = 1; // default: iPhone 16
119
+ int _iosIndex = 1; // default: iPhone 16
96
120
  int _androidIndex = 1; // default: Google Pixel 9
97
121
  int _iPadIndex = 0;
98
122
  bool _controlsVisible = false;
99
123
  Timer? _controlsTimer;
124
+ String? _toast;
125
+ bool _toastIsError = false;
126
+ Timer? _toastTimer;
100
127
 
101
- final ValueNotifier<DeviceInfo> _deviceNotifier =
102
- ValueNotifier<DeviceInfo>(_iosDevices[1]); // iPhone 16
128
+ final ValueNotifier<DeviceInfo> _deviceNotifier = ValueNotifier<DeviceInfo>(
129
+ _iosDevices[1],
130
+ ); // iPhone 16
103
131
  final ValueNotifier<bool> _frameVisibleNotifier = ValueNotifier<bool>(true);
104
132
  final ValueNotifier<bool> _bgDarkNotifier = ValueNotifier<bool>(false);
105
133
  final ValueNotifier<bool> _landscapeNotifier = ValueNotifier<bool>(false);
@@ -110,16 +138,16 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
110
138
  static const _textScaleSteps = [1.0, 1.3, 1.5];
111
139
 
112
140
  List<DeviceInfo> get _devices => switch (_platform) {
113
- 1 => _androidDevices,
114
- 2 => _iPadDevices,
115
- _ => _iosDevices,
116
- };
141
+ 1 => _androidDevices,
142
+ 2 => _iPadDevices,
143
+ _ => _iosDevices,
144
+ };
117
145
 
118
146
  int get _currentIndex => switch (_platform) {
119
- 1 => _androidIndex,
120
- 2 => _iPadIndex,
121
- _ => _iosIndex,
122
- };
147
+ 1 => _androidIndex,
148
+ 2 => _iPadIndex,
149
+ _ => _iosIndex,
150
+ };
123
151
 
124
152
  DeviceInfo get _currentDevice => _devices[_currentIndex];
125
153
 
@@ -132,6 +160,7 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
132
160
  );
133
161
  webDevicePreviewEnabledNotifier.addListener(_onEnabledChanged);
134
162
  _bgDarkNotifier.addListener(_onBgChanged);
163
+ devInspectorToastNotifier.addListener(_onInspectorToast);
135
164
  HardwareKeyboard.instance.addHandler(_handleKeyEvent);
136
165
  unawaited(_bootstrap());
137
166
  }
@@ -174,6 +203,7 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
174
203
  HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
175
204
  webDevicePreviewEnabledNotifier.removeListener(_onEnabledChanged);
176
205
  _bgDarkNotifier.removeListener(_onBgChanged);
206
+ devInspectorToastNotifier.removeListener(_onInspectorToast);
177
207
  _deviceNotifier.dispose();
178
208
  _frameVisibleNotifier.dispose();
179
209
  _bgDarkNotifier.dispose();
@@ -181,9 +211,31 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
181
211
  _textScaleNotifier.dispose();
182
212
  _localeNotifier.dispose();
183
213
  _controlsTimer?.cancel();
214
+ _toastTimer?.cancel();
184
215
  super.dispose();
185
216
  }
186
217
 
218
+ /// Transient confirmation pill shown in the chrome (e.g. "Image copied").
219
+ void _showToast(String message, {bool isError = false}) {
220
+ _toastTimer?.cancel();
221
+ setState(() {
222
+ _toast = message;
223
+ _toastIsError = isError;
224
+ });
225
+ _toastTimer = Timer(const Duration(milliseconds: 2200), () {
226
+ if (mounted) setState(() => _toast = null);
227
+ });
228
+ }
229
+
230
+ /// The DevInspector hands its "copied" message here so it renders in the same
231
+ /// spot and size as the chrome's own toasts (instead of inside the device).
232
+ void _onInspectorToast() {
233
+ final msg = devInspectorToastNotifier.value;
234
+ if (msg == null) return;
235
+ devInspectorToastNotifier.value = null;
236
+ if (mounted) _showToast(msg.message, isError: msg.isError);
237
+ }
238
+
187
239
  void _onEnabledChanged() {
188
240
  // Our chrome already surfaces inspector state via the pill, so suppress
189
241
  // the DevInspector's in-app status pill while the preview is on.
@@ -198,14 +250,55 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
198
250
  if (mounted) setState(() => _controlsVisible = true);
199
251
  });
200
252
  setState(() {}); // rebuild DevicePreview with enabled: true
253
+ // [webDevicePreviewActiveNotifier] flips to true only once the frame is
254
+ // actually built — see [_buildFramedContent] — so the web scale stays put
255
+ // until then (no big-unframed-app flash during the build).
201
256
  } else {
202
257
  _controlsTimer?.cancel();
258
+ // Leaving the frame: restore the web scale immediately.
259
+ webDevicePreviewActiveNotifier.value = false;
203
260
  setState(() => _controlsVisible = false);
204
261
  }
205
262
  }
206
263
 
207
264
  void _onBgChanged() => setState(() {});
208
265
 
266
+ /// Builds the app content placed INSIDE the device frame. DevicePreview calls
267
+ /// this as it brings the framed environment up, so it doubles as the "frame is
268
+ /// on screen now" signal: after this paints we flip
269
+ /// [webDevicePreviewActiveNotifier], which is what lets the web viewport scale
270
+ /// drop to native 1.0 — and only then, so the toggle never shows a flash of
271
+ /// big, unframed, unscaled app while the frame is still building.
272
+ Widget _buildFramedContent(BuildContext context) {
273
+ if (webDevicePreviewEnabledNotifier.value &&
274
+ !webDevicePreviewActiveNotifier.value) {
275
+ WidgetsBinding.instance.addPostFrameCallback((_) {
276
+ if (mounted && webDevicePreviewEnabledNotifier.value) {
277
+ webDevicePreviewActiveNotifier.value = true;
278
+ }
279
+ });
280
+ }
281
+ return ListenableBuilder(
282
+ listenable: _textScaleNotifier,
283
+ builder: (ctx, _) => MediaQuery(
284
+ data: MediaQuery.of(ctx).copyWith(
285
+ textScaler: TextScaler.linear(_textScaleNotifier.value),
286
+ // DevicePreview hard-codes platformBrightness to light inside the
287
+ // simulated frame, which breaks MaterialApp's ThemeMode.system.
288
+ // Forward the real host brightness so "system" tracks the OS.
289
+ platformBrightness:
290
+ WidgetsBinding.instance.platformDispatcher.platformBrightness,
291
+ ),
292
+ child: _DeviceSwitchBridge(
293
+ deviceNotifier: _deviceNotifier,
294
+ frameVisibleNotifier: _frameVisibleNotifier,
295
+ landscapeNotifier: _landscapeNotifier,
296
+ child: widget.child,
297
+ ),
298
+ ),
299
+ );
300
+ }
301
+
209
302
  Future<void> _bootstrap() async {
210
303
  final prefs = await SharedPreferences.getInstance();
211
304
 
@@ -283,9 +376,16 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
283
376
  }
284
377
 
285
378
  void _setPlatform(int p) {
286
- setState(() => _platform = p);
379
+ setState(() {
380
+ _platform = p;
381
+ // Choosing a platform always lands on its first device:
382
+ // iPad → plain iPad, Android → Samsung Galaxy A50.
383
+ if (p == 1) _androidIndex = 0;
384
+ if (p == 2) _iPadIndex = 0;
385
+ });
287
386
  _deviceNotifier.value = _currentDevice;
288
387
  unawaited(_savePlatform());
388
+ if (p == 1 || p == 2) unawaited(_saveDeviceIndices());
289
389
  }
290
390
 
291
391
  void _toggleFrame() {
@@ -301,10 +401,6 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
301
401
  devInspectorActiveNotifier.value = !devInspectorActiveNotifier.value;
302
402
  }
303
403
 
304
- void _copyInspection() {
305
- devInspectorCopyTriggerNotifier.value = true;
306
- }
307
-
308
404
  void _toggleLandscape() {
309
405
  _landscapeNotifier.value = !_landscapeNotifier.value;
310
406
  unawaited(_saveLandscape());
@@ -330,44 +426,34 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
330
426
  }
331
427
 
332
428
  Future<void> _takeScreenshot() async {
333
- final boundary = _screenshotKey.currentContext?.findRenderObject()
334
- as RenderRepaintBoundary?;
429
+ final boundary =
430
+ _screenshotKey.currentContext?.findRenderObject()
431
+ as RenderRepaintBoundary?;
335
432
  if (boundary == null) return;
336
433
  final dpr =
337
- WidgetsBinding.instance.platformDispatcher.implicitView?.devicePixelRatio
338
- ?? 2.0;
434
+ WidgetsBinding
435
+ .instance
436
+ .platformDispatcher
437
+ .implicitView
438
+ ?.devicePixelRatio ??
439
+ 2.0;
339
440
  final image = await boundary.toImage(pixelRatio: dpr);
340
441
  final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
341
442
  if (byteData == null) return;
342
443
  final bytes = byteData.buffer.asUint8List();
343
- final blob = html.Blob([bytes], 'image/png');
344
- final url = html.Url.createObjectUrlFromBlob(blob);
345
- html.AnchorElement(href: url)
346
- ..setAttribute(
347
- 'download',
348
- 'preview_${DateTime.now().millisecondsSinceEpoch}.png',
349
- )
350
- ..click();
351
- html.Url.revokeObjectUrl(url);
352
- }
353
-
354
- void _prev() {
355
- setState(() {
356
- switch (_platform) {
357
- case 1:
358
- _androidIndex =
359
- (_androidIndex - 1 + _androidDevices.length) %
360
- _androidDevices.length;
361
- case 2:
362
- _iPadIndex =
363
- (_iPadIndex - 1 + _iPadDevices.length) % _iPadDevices.length;
364
- default:
365
- _iosIndex =
366
- (_iosIndex - 1 + _iosDevices.length) % _iosDevices.length;
367
- }
368
- });
369
- _deviceNotifier.value = _currentDevice;
370
- unawaited(_saveDeviceIndices());
444
+ // Copy the PNG to the clipboard (with a download fallback). The web-only
445
+ // implementation lives behind a conditional import (png_clipboard.dart) so
446
+ // this file stays VM/native-compilable and never breaks `flutter test`.
447
+ final result = await copyOrDownloadPng(bytes);
448
+ if (!mounted) return;
449
+ switch (result) {
450
+ case PngExportResult.copied:
451
+ _showToast(t.webDevicePreview.imageCopied);
452
+ case PngExportResult.downloaded:
453
+ _showToast(t.webDevicePreview.imageDownloaded);
454
+ case PngExportResult.unavailable:
455
+ break;
456
+ }
371
457
  }
372
458
 
373
459
  void _next() {
@@ -389,102 +475,139 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
389
475
  Widget build(BuildContext context) {
390
476
  final enabled = webDevicePreviewEnabledNotifier.value;
391
477
 
478
+ // App theme (light/dark) lives in ThemeProvider, which sits ABOVE this
479
+ // widget — reading it here rebuilds the toolbar when the theme flips.
480
+ final AppTheme appTheme = ThemeProvider.of(context);
481
+ final bool appDark = appTheme.effectiveMode == ThemeMode.dark;
482
+
392
483
  // ThemeData.light() in Flutter 3.x M3 uses a lavender-tinted surface.
393
484
  // Passing backgroundColor directly avoids that and gives us an exact color.
394
- final canvasColor =
395
- _bgDarkNotifier.value ? const Color(0xFF1C1C1E) : Colors.white;
485
+ final canvasColor = _bgDarkNotifier.value
486
+ ? const Color(0xFF1C1C1E)
487
+ : Colors.white;
396
488
 
397
489
  final preview = DevicePreview(
398
490
  enabled: enabled,
399
491
  isToolbarVisible: false,
400
492
  backgroundColor: canvasColor,
493
+ // Reserve room at the top so the floating toolbar never sits on the device.
494
+ // DevicePreview fills the whole viewport, so this is the single source of
495
+ // background — there is no second surface that could create a seam.
496
+ padding: enabled
497
+ ? const EdgeInsets.only(top: 56, left: 20, right: 20, bottom: 20)
498
+ : null,
401
499
  storage: DevicePreviewStorage.none(),
402
500
  defaultDevice: _currentDevice,
403
501
  devices: [..._iosDevices, ..._androidDevices, ..._iPadDevices],
404
- builder: (context) => ListenableBuilder(
405
- listenable: _textScaleNotifier,
406
- builder: (ctx, _) => MediaQuery(
407
- data: MediaQuery.of(ctx).copyWith(
408
- textScaler: TextScaler.linear(_textScaleNotifier.value),
409
- // DevicePreview hard-codes platformBrightness to light inside the
410
- // simulated frame, which breaks MaterialApp's ThemeMode.system.
411
- // Forward the real host brightness so "system" tracks the OS.
412
- platformBrightness: WidgetsBinding
413
- .instance.platformDispatcher.platformBrightness,
414
- ),
415
- child: _DeviceSwitchBridge(
416
- deviceNotifier: _deviceNotifier,
417
- frameVisibleNotifier: _frameVisibleNotifier,
418
- landscapeNotifier: _landscapeNotifier,
419
- child: widget.child,
420
- ),
421
- ),
422
- ),
502
+ builder: _buildFramedContent,
423
503
  );
424
504
 
425
505
  if (!enabled) return preview;
426
506
 
427
507
  return TapRegionSurface(
428
508
  child: Directionality(
429
- textDirection: TextDirection.ltr,
430
- child: Stack(
431
- fit: StackFit.expand,
432
- children: [
433
- RepaintBoundary(key: _screenshotKey, child: preview),
434
- // Inspector highlight drawn ABOVE the device frame so widgets that
435
- // hug the edges of the simulated viewport (AppBar, bottom nav, …)
436
- // still get a complete border instead of being clipped by the chrome.
437
- const Positioned.fill(
438
- child: IgnorePointer(
439
- child: _DevInspectorExternalHighlight(),
509
+ textDirection: TextDirection.ltr,
510
+ child: Stack(
511
+ children: [
512
+ // DevicePreview paints the entire viewport (single background, no
513
+ // seam). The device frame is pushed below the toolbar by the top
514
+ // padding above, so the toolbar never overlaps it.
515
+ RepaintBoundary(key: _screenshotKey, child: preview),
516
+ // Inspector highlight drawn ABOVE the device frame so widgets that
517
+ // hug the viewport edges (AppBar, bottom nav, …) keep a full border.
518
+ const Positioned.fill(
519
+ child: IgnorePointer(child: _DevInspectorExternalHighlight()),
440
520
  ),
441
- ),
442
- Positioned(
443
- top: 24,
444
- right: 24,
445
- child: AnimatedOpacity(
446
- opacity: _controlsVisible ? 1.0 : 0.0,
447
- duration: const Duration(milliseconds: 400),
521
+ // Floating toolbar — overlaid at the top, centered. Opening its
522
+ // dropdowns never pushes or resizes the device (they overlay).
523
+ Positioned(
524
+ top: 12,
525
+ left: 0,
526
+ right: 0,
527
+ child: AnimatedOpacity(
528
+ opacity: _controlsVisible ? 1.0 : 0.0,
529
+ duration: const Duration(milliseconds: 400),
530
+ child: IgnorePointer(
531
+ ignoring: !_controlsVisible,
532
+ child: Center(
533
+ child: ListenableBuilder(
534
+ listenable: Listenable.merge([
535
+ _frameVisibleNotifier,
536
+ _bgDarkNotifier,
537
+ _landscapeNotifier,
538
+ _textScaleNotifier,
539
+ _localeNotifier,
540
+ devInspectorActiveNotifier,
541
+ ]),
542
+ builder: (context, child) => _PreviewControls(
543
+ platform: _platform,
544
+ deviceName: _currentDevice.name,
545
+ dark: _bgDarkNotifier.value,
546
+ appDark: appDark,
547
+ onToggleAppTheme: appTheme.toggle,
548
+ frameVisible: _frameVisibleNotifier.value,
549
+ isLandscape: _landscapeNotifier.value,
550
+ textScale: _textScaleNotifier.value,
551
+ currentLocale: _localeNotifier.value,
552
+ inspectorEnabled: devInspectorActiveNotifier.value,
553
+ onPlatformChanged: _setPlatform,
554
+ onNext: _next,
555
+ onToggleFrame: _toggleFrame,
556
+ onToggleLandscape: _toggleLandscape,
557
+ onCycleTextScale: _cycleTextScale,
558
+ onCycleLocale: _cycleLocale,
559
+ onToggleInspector: _toggleInspector,
560
+ onScreenshot: () => unawaited(_takeScreenshot()),
561
+ onClose: () => unawaited(_close()),
562
+ ),
563
+ ),
564
+ ),
565
+ ),
566
+ ),
567
+ ),
568
+ // Theme toggle — standalone sun/moon button pinned to the
569
+ // bottom-right corner, independent of the toolbar.
570
+ Positioned(
571
+ bottom: 10,
572
+ right: 10,
573
+ child: AnimatedOpacity(
574
+ opacity: _controlsVisible ? 1.0 : 0.0,
575
+ duration: const Duration(milliseconds: 400),
576
+ child: IgnorePointer(
577
+ ignoring: !_controlsVisible,
578
+ child: ListenableBuilder(
579
+ listenable: _bgDarkNotifier,
580
+ builder: (context, child) => _ThemeCornerButton(
581
+ dark: _bgDarkNotifier.value,
582
+ onTap: _toggleBg,
583
+ ),
584
+ ),
585
+ ),
586
+ ),
587
+ ),
588
+ // Transient confirmation toast (e.g. screenshot copied).
589
+ Positioned(
590
+ left: 0,
591
+ right: 0,
592
+ bottom: 24,
448
593
  child: IgnorePointer(
449
- ignoring: !_controlsVisible,
450
- child: ListenableBuilder(
451
- listenable: Listenable.merge([
452
- _frameVisibleNotifier,
453
- _bgDarkNotifier,
454
- _landscapeNotifier,
455
- _textScaleNotifier,
456
- _localeNotifier,
457
- devInspectorActiveNotifier,
458
- ]),
459
- builder: (context, child) => _PreviewControls(
460
- platform: _platform,
461
- deviceName: _shortName(_currentDevice.name),
462
- frameVisible: _frameVisibleNotifier.value,
463
- bgDark: _bgDarkNotifier.value,
464
- isLandscape: _landscapeNotifier.value,
465
- textScale: _textScaleNotifier.value,
466
- currentLocale: _localeNotifier.value,
467
- inspectorEnabled: devInspectorActiveNotifier.value,
468
- onPlatformChanged: _setPlatform,
469
- onPrev: _prev,
470
- onNext: _next,
471
- onToggleFrame: _toggleFrame,
472
- onToggleBg: _toggleBg,
473
- onToggleLandscape: _toggleLandscape,
474
- onCycleTextScale: _cycleTextScale,
475
- onCycleLocale: _cycleLocale,
476
- onToggleInspector: _toggleInspector,
477
- onCopyInspection: _copyInspection,
478
- onScreenshot: () => unawaited(_takeScreenshot()),
479
- onClose: () => unawaited(_close()),
594
+ child: Center(
595
+ child: AnimatedSwitcher(
596
+ duration: const Duration(milliseconds: 200),
597
+ child: _toast == null
598
+ ? const SizedBox.shrink()
599
+ : _Toast(
600
+ key: ValueKey(_toast),
601
+ message: _toast!,
602
+ isError: _toastIsError,
603
+ ),
480
604
  ),
481
605
  ),
482
606
  ),
483
607
  ),
484
- ),
485
- ],
608
+ ],
609
+ ),
486
610
  ),
487
- ),
488
611
  );
489
612
  }
490
613
  }
@@ -607,44 +730,45 @@ class _PreviewControls extends StatefulWidget {
607
730
  const _PreviewControls({
608
731
  required this.platform,
609
732
  required this.deviceName,
733
+ required this.dark,
734
+ required this.appDark,
735
+ required this.onToggleAppTheme,
610
736
  required this.frameVisible,
611
- required this.bgDark,
612
737
  required this.isLandscape,
613
738
  required this.textScale,
614
739
  required this.currentLocale,
615
740
  required this.inspectorEnabled,
616
741
  required this.onPlatformChanged,
617
- required this.onPrev,
618
742
  required this.onNext,
619
743
  required this.onToggleFrame,
620
- required this.onToggleBg,
621
744
  required this.onToggleLandscape,
622
745
  required this.onCycleTextScale,
623
746
  required this.onCycleLocale,
624
747
  required this.onToggleInspector,
625
- required this.onCopyInspection,
626
748
  required this.onScreenshot,
627
749
  required this.onClose,
628
750
  });
629
751
 
630
752
  final int platform;
631
753
  final String deviceName;
754
+ final bool dark;
755
+
756
+ /// App theme (light/dark) state + toggle — flips the real app ThemeMode.
757
+ final bool appDark;
758
+ final VoidCallback onToggleAppTheme;
759
+
632
760
  final bool frameVisible;
633
- final bool bgDark;
634
761
  final bool isLandscape;
635
762
  final double textScale;
636
763
  final AppLocale currentLocale;
637
764
  final bool inspectorEnabled;
638
765
  final ValueChanged<int> onPlatformChanged;
639
- final VoidCallback onPrev;
640
766
  final VoidCallback onNext;
641
767
  final VoidCallback onToggleFrame;
642
- final VoidCallback onToggleBg;
643
768
  final VoidCallback onToggleLandscape;
644
769
  final VoidCallback onCycleTextScale;
645
770
  final VoidCallback onCycleLocale;
646
771
  final VoidCallback onToggleInspector;
647
- final VoidCallback onCopyInspection;
648
772
  final VoidCallback onScreenshot;
649
773
  final VoidCallback onClose;
650
774
 
@@ -654,137 +778,60 @@ class _PreviewControls extends StatefulWidget {
654
778
 
655
779
  class _PreviewControlsState extends State<_PreviewControls> {
656
780
  bool _menuOpen = false;
657
- bool _toolsMenuOpen = false;
658
- bool _minimized = true;
659
781
  bool _platformBtnHovered = false;
660
- bool _toolsBtnHovered = false;
661
- bool _shadowVisible = true;
662
- Timer? _shadowTimer;
782
+ bool _deviceBtnHovered = false;
663
783
 
664
784
  static const _platformLabels = ['iOS', 'Android', 'iPad'];
665
785
 
666
- static const _pillDecoration = BoxDecoration(
667
- color: Color(0xFF2C2C2E),
668
- borderRadius: BorderRadius.all(Radius.circular(24)),
786
+ // Color scheme adapts to the preview theme: dark pill (white content) in dark
787
+ // mode, white pill (dark content) in light mode.
788
+ Color get _pillColor => widget.dark ? const Color(0xFF2C2C2E) : Colors.white;
789
+ Color get _menuColor => widget.dark ? const Color(0xFF3A3A3C) : Colors.white;
790
+ // Foreground (text/icons) base — alphas are derived from this.
791
+ Color get _fg => widget.dark ? Colors.white : const Color(0xFF1C1C1E);
792
+
793
+ BoxDecoration get _pillDecoration => BoxDecoration(
794
+ color: _pillColor,
795
+ borderRadius: const BorderRadius.all(Radius.circular(24)),
669
796
  );
670
797
 
798
+ // Tight, low-spread shadow: enough to lift the pill off the canvas without
799
+ // casting a wide smudge below it that reads as a horizontal "division" line.
671
800
  static const _pillShadow = [
672
- BoxShadow(
673
- color: Color(0x73000000),
674
- blurRadius: 20,
675
- offset: Offset(0, 6),
676
- ),
801
+ BoxShadow(color: Color(0x33000000), blurRadius: 8, offset: Offset(0, 2)),
677
802
  ];
678
803
 
679
804
  static String _textScaleLabel(double scale) => switch (scale) {
680
- 1.0 => '1×',
681
- 1.3 => '1.3×',
682
- _ => '1.5×',
683
- };
684
-
685
- void _toggleMenu() => setState(() {
686
- _menuOpen = !_menuOpen;
687
- if (_menuOpen) _toolsMenuOpen = false;
688
- });
805
+ 1.0 => '1×',
806
+ 1.3 => '1.3×',
807
+ _ => '1.5×',
808
+ };
689
809
 
690
- void _toggleToolsMenu() => setState(() {
691
- _toolsMenuOpen = !_toolsMenuOpen;
692
- if (_toolsMenuOpen) _menuOpen = false;
693
- });
810
+ void _toggleMenu() => setState(() => _menuOpen = !_menuOpen);
694
811
 
695
812
  void _selectPlatform(int i) {
696
813
  widget.onPlatformChanged(i);
697
- setState(() {
698
- _menuOpen = false;
699
- _toolsMenuOpen = false;
700
- });
701
- }
702
-
703
- @override
704
- void dispose() {
705
- _shadowTimer?.cancel();
706
- super.dispose();
707
- }
708
-
709
- void _hideShadowDuringAnimation() {
710
- _shadowTimer?.cancel();
711
- setState(() => _shadowVisible = false);
712
- _shadowTimer = Timer(const Duration(milliseconds: 140), () {
713
- if (mounted) setState(() => _shadowVisible = true);
714
- });
715
- }
716
-
717
- void _minimize() {
718
- setState(() {
719
- _minimized = true;
720
- _menuOpen = false;
721
- _toolsMenuOpen = false;
722
- });
723
- _hideShadowDuringAnimation();
724
- }
725
-
726
- void _maximize() {
727
- setState(() => _minimized = false);
728
- _hideShadowDuringAnimation();
814
+ setState(() => _menuOpen = false);
729
815
  }
730
816
 
731
817
  @override
732
818
  Widget build(BuildContext context) {
733
819
  return TapRegion(
734
820
  onTapOutside: (_) {
735
- if (_menuOpen || _toolsMenuOpen) {
736
- setState(() {
737
- _menuOpen = false;
738
- _toolsMenuOpen = false;
739
- });
740
- }
821
+ if (_menuOpen) setState(() => _menuOpen = false);
741
822
  },
742
823
  child: Column(
743
824
  crossAxisAlignment: CrossAxisAlignment.start,
744
825
  mainAxisSize: MainAxisSize.min,
745
826
  children: [
746
- AnimatedOpacity(
747
- opacity: _shadowVisible ? 1.0 : 0.0,
748
- duration: const Duration(milliseconds: 80),
749
- child: DecoratedBox(
750
- decoration: const BoxDecoration(
751
- borderRadius: BorderRadius.all(Radius.circular(24)),
752
- boxShadow: _pillShadow,
753
- ),
754
- child: AnimatedSize(
755
- duration: const Duration(milliseconds: 140),
756
- curve: Curves.easeInOutCubic,
757
- alignment: Alignment.centerRight,
758
- child: _minimized ? _buildMinimizedPill() : _buildPill(),
759
- ),
827
+ DecoratedBox(
828
+ decoration: const BoxDecoration(
829
+ borderRadius: BorderRadius.all(Radius.circular(24)),
830
+ boxShadow: _pillShadow,
760
831
  ),
832
+ child: _buildPill(),
761
833
  ),
762
- if (_menuOpen && !_minimized) _buildMenu(),
763
- if (_toolsMenuOpen && !_minimized) _buildToolsMenu(),
764
- ],
765
- ),
766
- );
767
- }
768
-
769
- Widget _buildMinimizedPill() {
770
- return Container(
771
- height: 40,
772
- padding: const EdgeInsets.symmetric(horizontal: 4),
773
- decoration: _pillDecoration,
774
- child: Row(
775
- mainAxisSize: MainAxisSize.min,
776
- children: [
777
- _IconBtn(
778
- icon: Icons.unfold_more_rounded,
779
- onTap: _maximize,
780
- ),
781
- _VerticalDivider(),
782
- _IconBtn(
783
- icon: Icons.close_rounded,
784
- onTap: widget.onClose,
785
- color: Colors.white54,
786
- ),
787
- const SizedBox(width: 2),
834
+ if (_menuOpen) _buildMenu(),
788
835
  ],
789
836
  ),
790
837
  );
@@ -792,7 +839,7 @@ class _PreviewControlsState extends State<_PreviewControls> {
792
839
 
793
840
  Widget _buildPill() {
794
841
  return Container(
795
- height: 40,
842
+ height: 34,
796
843
  padding: const EdgeInsets.symmetric(horizontal: 4),
797
844
  decoration: _pillDecoration,
798
845
  child: Row(
@@ -804,131 +851,144 @@ class _PreviewControlsState extends State<_PreviewControls> {
804
851
  onExit: (_) => setState(() => _platformBtnHovered = false),
805
852
  cursor: SystemMouseCursors.click,
806
853
  child: GestureDetector(
807
- onTap: _toggleMenu,
808
- child: AnimatedContainer(
809
- duration: const Duration(milliseconds: 160),
810
- margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
811
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
812
- decoration: BoxDecoration(
813
- color: Colors.white.withValues(
814
- alpha: _menuOpen ? 0.22 : (_platformBtnHovered ? 0.20 : 0.14),
815
- ),
816
- borderRadius: BorderRadius.circular(20),
817
- ),
818
- child: Row(
819
- mainAxisSize: MainAxisSize.min,
820
- children: [
821
- Text(
822
- _platformLabels[widget.platform],
823
- style: const TextStyle(
824
- color: Colors.white,
825
- fontSize: 12,
826
- fontWeight: FontWeight.w600,
827
- ),
828
- ),
829
- const SizedBox(width: 2),
830
- AnimatedRotation(
831
- turns: _menuOpen ? 0.5 : 0,
832
- duration: const Duration(milliseconds: 160),
833
- child: const Icon(
834
- Icons.keyboard_arrow_down_rounded,
835
- color: Colors.white70,
836
- size: 13,
837
- ),
838
- ),
839
- ],
840
- ),
841
- ),
842
- ),
843
- ),
844
- _VerticalDivider(),
845
- _IconBtn(icon: Icons.chevron_left_rounded, onTap: widget.onPrev),
846
- Padding(
847
- padding: const EdgeInsets.symmetric(horizontal: 8),
848
- child: Text(
849
- widget.deviceName,
850
- style: const TextStyle(
851
- color: Colors.white,
852
- fontSize: 12,
853
- fontWeight: FontWeight.w500,
854
- letterSpacing: -0.2,
855
- ),
856
- ),
857
- ),
858
- _IconBtn(icon: Icons.chevron_right_rounded, onTap: widget.onNext),
859
- // Locale chip
860
- _VerticalDivider(),
861
- _PillChip(
862
- label: widget.currentLocale.languageCode.toUpperCase(),
863
- onTap: widget.onCycleLocale,
864
- ),
865
- // Tools dropdown
866
- _VerticalDivider(),
867
- MouseRegion(
868
- onEnter: (_) => setState(() => _toolsBtnHovered = true),
869
- onExit: (_) => setState(() => _toolsBtnHovered = false),
870
- cursor: SystemMouseCursors.click,
871
- child: GestureDetector(
872
- onTap: _toggleToolsMenu,
854
+ onTap: _toggleMenu,
873
855
  child: AnimatedContainer(
874
856
  duration: const Duration(milliseconds: 160),
875
857
  margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
876
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
858
+ padding: const EdgeInsets.symmetric(
859
+ horizontal: 10,
860
+ vertical: 5,
861
+ ),
877
862
  decoration: BoxDecoration(
878
- color: Colors.white.withValues(
879
- alpha: _toolsMenuOpen ? 0.22 : (_toolsBtnHovered ? 0.20 : 0.14),
863
+ // No resting fill — it just alternates platforms. Subtle fill
864
+ // only while open or hovered.
865
+ color: _fg.withValues(
866
+ alpha: _menuOpen
867
+ ? 0.16
868
+ : (_platformBtnHovered ? 0.12 : 0.0),
880
869
  ),
881
870
  borderRadius: BorderRadius.circular(20),
882
871
  ),
883
872
  child: Row(
884
873
  mainAxisSize: MainAxisSize.min,
885
874
  children: [
886
- const Text(
887
- 'Tools',
888
- style: TextStyle(
889
- color: Colors.white,
890
- fontSize: 12,
891
- fontWeight: FontWeight.w600,
875
+ Text(
876
+ _platformLabels[widget.platform],
877
+ style: _chromeText(
878
+ size: 12,
879
+ weight: FontWeight.w600,
880
+ color: _fg,
892
881
  ),
893
882
  ),
894
883
  const SizedBox(width: 2),
895
884
  AnimatedRotation(
896
- turns: _toolsMenuOpen ? 0.5 : 0,
885
+ turns: _menuOpen ? 0.5 : 0,
897
886
  duration: const Duration(milliseconds: 160),
898
- child: const Icon(
899
- Icons.keyboard_arrow_down_rounded,
900
- color: Colors.white70,
901
- size: 13,
902
- ),
887
+ child: Icon(KasyIcons.chevronDown, color: _fg, size: 13),
903
888
  ),
904
889
  ],
905
890
  ),
906
891
  ),
907
892
  ),
908
893
  ),
909
- // Inspector group
910
- _VerticalDivider(),
894
+ _VerticalDivider(base: _fg),
895
+ // Tapping the name cycles to the next device — no side chevrons, saves
896
+ // space (same pattern as the locale cycler).
897
+ MouseRegion(
898
+ onEnter: (_) => setState(() => _deviceBtnHovered = true),
899
+ onExit: (_) => setState(() => _deviceBtnHovered = false),
900
+ cursor: SystemMouseCursors.click,
901
+ child: GestureDetector(
902
+ onTap: widget.onNext,
903
+ child: AnimatedContainer(
904
+ duration: const Duration(milliseconds: 160),
905
+ margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
906
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
907
+ decoration: BoxDecoration(
908
+ color: _fg.withValues(alpha: _deviceBtnHovered ? 0.12 : 0.0),
909
+ borderRadius: BorderRadius.circular(20),
910
+ ),
911
+ child: Text(
912
+ widget.deviceName,
913
+ style: _chromeText(
914
+ size: 12,
915
+ weight: FontWeight.w500,
916
+ color: _fg,
917
+ spacing: -0.2,
918
+ ),
919
+ ),
920
+ ),
921
+ ),
922
+ ),
923
+ // Locale chip — hidden for now (see ROADMAP: moves to vertical bar).
924
+ if (_showSecondaryTools()) ...[
925
+ _VerticalDivider(base: _fg),
926
+ _PillChip(
927
+ label: widget.currentLocale.languageCode.toUpperCase(),
928
+ onTap: widget.onCycleLocale,
929
+ base: _fg,
930
+ ),
931
+ ],
932
+ // Tools, all inline (no dropdown).
933
+ _VerticalDivider(base: _fg),
934
+ // Frame is a mode switch (with / without device frame), not an on/off
935
+ // toggle — the icon itself flips, so no accent highlight.
911
936
  _IconBtn(
912
- icon: Icons.touch_app_rounded,
913
- onTap: widget.onToggleInspector,
914
- color: widget.inspectorEnabled ? Colors.white : Colors.white30,
937
+ icon: widget.frameVisible
938
+ ? KasyIcons.deviceFrame
939
+ : KasyIcons.deviceFrameOff,
940
+ onTap: widget.onToggleFrame,
941
+ base: _fg,
942
+ color: _fg,
915
943
  ),
916
- if (widget.inspectorEnabled)
944
+ // App theme switch (light/dark of the app inside the device). Mode
945
+ // switch like frame — the sun/moon icon flips, so no accent fill.
946
+ _IconBtn(
947
+ icon: widget.appDark ? KasyIcons.darkMode : KasyIcons.lightMode,
948
+ onTap: widget.onToggleAppTheme,
949
+ base: _fg,
950
+ color: _fg,
951
+ ),
952
+ // Orientation + text-scale hidden for now (see ROADMAP flag).
953
+ if (_showSecondaryTools()) ...[
917
954
  _IconBtn(
918
- icon: Icons.content_copy_rounded,
919
- onTap: widget.onCopyInspection,
955
+ icon: widget.isLandscape
956
+ ? KasyIcons.landscape
957
+ : KasyIcons.portrait,
958
+ onTap: widget.onToggleLandscape,
959
+ base: _fg,
960
+ active: widget.isLandscape,
961
+ color: _fg,
920
962
  ),
921
- // Utilities: minimize · close
922
- _VerticalDivider(),
963
+ _PillChip(
964
+ label: _textScaleLabel(widget.textScale),
965
+ onTap: widget.onCycleTextScale,
966
+ base: _fg,
967
+ active: widget.textScale != 1.0,
968
+ ),
969
+ ],
923
970
  _IconBtn(
924
- icon: Icons.unfold_less_rounded,
925
- onTap: _minimize,
926
- color: Colors.white30,
971
+ icon: KasyIcons.cameraAlt,
972
+ onTap: widget.onScreenshot,
973
+ base: _fg,
974
+ color: _fg,
975
+ ),
976
+ // Inspector group
977
+ _VerticalDivider(base: _fg),
978
+ _IconBtn(
979
+ icon: KasyIcons.inspector,
980
+ onTap: widget.onToggleInspector,
981
+ base: _fg,
982
+ active: widget.inspectorEnabled,
983
+ color: _fg,
927
984
  ),
985
+ // Close
986
+ _VerticalDivider(base: _fg),
928
987
  _IconBtn(
929
- icon: Icons.close_rounded,
988
+ icon: KasyIcons.close,
930
989
  onTap: widget.onClose,
931
- color: Colors.white54,
990
+ base: _fg,
991
+ color: _fg,
932
992
  ),
933
993
  const SizedBox(width: 2),
934
994
  ],
@@ -941,11 +1001,11 @@ class _PreviewControlsState extends State<_PreviewControls> {
941
1001
  margin: const EdgeInsets.only(top: 6),
942
1002
  padding: const EdgeInsets.symmetric(vertical: 4),
943
1003
  decoration: BoxDecoration(
944
- color: const Color(0xFF3A3A3C),
1004
+ color: _menuColor,
945
1005
  borderRadius: BorderRadius.circular(12),
946
1006
  boxShadow: [
947
1007
  BoxShadow(
948
- color: Colors.black.withValues(alpha: 0.4),
1008
+ color: Colors.black.withValues(alpha: widget.dark ? 0.4 : 0.18),
949
1009
  blurRadius: 16,
950
1010
  offset: const Offset(0, 4),
951
1011
  ),
@@ -960,6 +1020,7 @@ class _PreviewControlsState extends State<_PreviewControls> {
960
1020
  return _DropdownItem(
961
1021
  label: _platformLabels[i],
962
1022
  selected: selected,
1023
+ base: _fg,
963
1024
  onTap: () => _selectPlatform(i),
964
1025
  );
965
1026
  }),
@@ -967,72 +1028,6 @@ class _PreviewControlsState extends State<_PreviewControls> {
967
1028
  ),
968
1029
  );
969
1030
  }
970
-
971
- Widget _buildToolsMenu() {
972
- return Container(
973
- margin: const EdgeInsets.only(top: 6),
974
- padding: const EdgeInsets.symmetric(vertical: 4),
975
- decoration: BoxDecoration(
976
- color: const Color(0xFF3A3A3C),
977
- borderRadius: BorderRadius.circular(12),
978
- boxShadow: [
979
- BoxShadow(
980
- color: Colors.black.withValues(alpha: 0.4),
981
- blurRadius: 16,
982
- offset: const Offset(0, 4),
983
- ),
984
- ],
985
- ),
986
- child: IntrinsicWidth(
987
- child: Column(
988
- crossAxisAlignment: CrossAxisAlignment.stretch,
989
- mainAxisSize: MainAxisSize.min,
990
- children: [
991
- _ToolsItem(
992
- icon: widget.frameVisible
993
- ? Icons.smartphone_rounded
994
- : Icons.crop_free_rounded,
995
- label: t.webDevicePreview.frame,
996
- checked: widget.frameVisible,
997
- onTap: widget.onToggleFrame,
998
- ),
999
- _ToolsItem(
1000
- icon: Icons.contrast_rounded,
1001
- label: t.webDevicePreview.darkBackground,
1002
- checked: widget.bgDark,
1003
- onTap: widget.onToggleBg,
1004
- ),
1005
- _ToolsItem(
1006
- icon: widget.isLandscape
1007
- ? Icons.stay_current_landscape_rounded
1008
- : Icons.stay_current_portrait_rounded,
1009
- label: t.webDevicePreview.landscape,
1010
- checked: widget.isLandscape,
1011
- onTap: widget.onToggleLandscape,
1012
- ),
1013
- _ToolsItem(
1014
- icon: Icons.text_fields_rounded,
1015
- label: t.webDevicePreview.textScale,
1016
- trailingLabel: _textScaleLabel(widget.textScale),
1017
- onTap: widget.onCycleTextScale,
1018
- ),
1019
- Divider(
1020
- height: 1,
1021
- color: Colors.white.withValues(alpha: 0.10),
1022
- ),
1023
- _ToolsItem(
1024
- icon: Icons.photo_camera_rounded,
1025
- label: t.webDevicePreview.screenshot,
1026
- onTap: () {
1027
- setState(() => _toolsMenuOpen = false);
1028
- widget.onScreenshot();
1029
- },
1030
- ),
1031
- ],
1032
- ),
1033
- ),
1034
- );
1035
- }
1036
1031
  }
1037
1032
 
1038
1033
  class _DropdownItem extends StatefulWidget {
@@ -1040,11 +1035,13 @@ class _DropdownItem extends StatefulWidget {
1040
1035
  required this.label,
1041
1036
  required this.selected,
1042
1037
  required this.onTap,
1038
+ this.base = Colors.white,
1043
1039
  });
1044
1040
 
1045
1041
  final String label;
1046
1042
  final bool selected;
1047
1043
  final VoidCallback onTap;
1044
+ final Color base;
1048
1045
 
1049
1046
  @override
1050
1047
  State<_DropdownItem> createState() => _DropdownItemState();
@@ -1065,15 +1062,16 @@ class _DropdownItemState extends State<_DropdownItem> {
1065
1062
  duration: const Duration(milliseconds: 120),
1066
1063
  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
1067
1064
  color: _hovered
1068
- ? Colors.white.withValues(alpha: 0.10)
1065
+ ? widget.base.withValues(alpha: 0.10)
1069
1066
  : Colors.transparent,
1070
1067
  child: Text(
1071
1068
  widget.label,
1072
- style: TextStyle(
1073
- color: widget.selected ? Colors.white : Colors.white54,
1074
- fontWeight:
1075
- widget.selected ? FontWeight.w600 : FontWeight.w400,
1076
- fontSize: 13,
1069
+ style: _chromeText(
1070
+ size: 13,
1071
+ weight: widget.selected ? FontWeight.w600 : FontWeight.w400,
1072
+ color: widget.selected
1073
+ ? widget.base
1074
+ : widget.base.withValues(alpha: 0.5),
1077
1075
  ),
1078
1076
  ),
1079
1077
  ),
@@ -1086,10 +1084,16 @@ class _PillChip extends StatefulWidget {
1086
1084
  const _PillChip({
1087
1085
  required this.label,
1088
1086
  required this.onTap,
1087
+ this.base = Colors.white,
1088
+ this.active = false,
1089
1089
  });
1090
1090
 
1091
1091
  final String label;
1092
1092
  final VoidCallback onTap;
1093
+ final Color base;
1094
+
1095
+ /// When true the chip reads as "non-default" (accent tint + accent text).
1096
+ final bool active;
1093
1097
 
1094
1098
  @override
1095
1099
  State<_PillChip> createState() => _PillChipState();
@@ -1100,6 +1104,14 @@ class _PillChipState extends State<_PillChip> {
1100
1104
 
1101
1105
  @override
1102
1106
  Widget build(BuildContext context) {
1107
+ final bool on = widget.active;
1108
+ final Color textColor = on ? Colors.white : widget.base;
1109
+ // No resting fill — it just cycles a value. Solid accent when active,
1110
+ // subtle fill only on hover otherwise.
1111
+ final Color bg = on
1112
+ ? (_hovered ? _kAccent.withValues(alpha: 0.85) : _kAccent)
1113
+ : (_hovered ? widget.base.withValues(alpha: 0.14) : Colors.transparent);
1114
+
1103
1115
  return MouseRegion(
1104
1116
  onEnter: (_) => setState(() => _hovered = true),
1105
1117
  onExit: (_) => setState(() => _hovered = false),
@@ -1111,16 +1123,16 @@ class _PillChipState extends State<_PillChip> {
1111
1123
  margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
1112
1124
  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
1113
1125
  decoration: BoxDecoration(
1114
- color: Colors.white.withValues(alpha: _hovered ? 0.22 : 0.14),
1126
+ color: bg,
1115
1127
  borderRadius: BorderRadius.circular(20),
1116
1128
  ),
1117
1129
  child: Text(
1118
1130
  widget.label,
1119
- style: const TextStyle(
1120
- color: Colors.white,
1121
- fontSize: 11,
1122
- fontWeight: FontWeight.w600,
1123
- letterSpacing: 0.3,
1131
+ style: _chromeText(
1132
+ size: 11,
1133
+ weight: FontWeight.w600,
1134
+ color: textColor,
1135
+ spacing: 0.3,
1124
1136
  ),
1125
1137
  ),
1126
1138
  ),
@@ -1129,75 +1141,111 @@ class _PillChipState extends State<_PillChip> {
1129
1141
  }
1130
1142
  }
1131
1143
 
1132
- class _ToolsItem extends StatefulWidget {
1133
- const _ToolsItem({
1134
- required this.icon,
1135
- required this.label,
1136
- required this.onTap,
1137
- this.checked,
1138
- this.trailingLabel,
1139
- });
1144
+ /// Small confirmation pill shown briefly in the chrome (e.g. after a screenshot
1145
+ /// is copied). Dark, frosted, centered near the bottom.
1146
+ class _Toast extends StatelessWidget {
1147
+ const _Toast({super.key, required this.message, this.isError = false});
1140
1148
 
1141
- final IconData icon;
1142
- final String label;
1149
+ final String message;
1150
+ final bool isError;
1151
+
1152
+ @override
1153
+ Widget build(BuildContext context) {
1154
+ return DecoratedBox(
1155
+ decoration: BoxDecoration(
1156
+ color: const Color(0xF02C2C2E),
1157
+ borderRadius: BorderRadius.circular(20),
1158
+ boxShadow: const [
1159
+ BoxShadow(color: Color(0x40000000), blurRadius: 12, offset: Offset(0, 3)),
1160
+ ],
1161
+ ),
1162
+ child: Padding(
1163
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
1164
+ child: Row(
1165
+ mainAxisSize: MainAxisSize.min,
1166
+ children: [
1167
+ Icon(
1168
+ isError ? KasyIcons.error : KasyIcons.checkCircle,
1169
+ color: isError
1170
+ ? const Color(0xFFFF453A)
1171
+ : const Color(0xFF34C759),
1172
+ size: 15,
1173
+ ),
1174
+ const SizedBox(width: 8),
1175
+ Text(
1176
+ message,
1177
+ style: _chromeText(
1178
+ size: 12,
1179
+ weight: FontWeight.w500,
1180
+ color: Colors.white,
1181
+ ),
1182
+ ),
1183
+ ],
1184
+ ),
1185
+ ),
1186
+ );
1187
+ }
1188
+ }
1189
+
1190
+ /// Standalone sun/moon theme toggle pinned to the top-right corner. Switches
1191
+ /// the preview canvas between light (sun) and dark (moon).
1192
+ class _ThemeCornerButton extends StatefulWidget {
1193
+ const _ThemeCornerButton({required this.dark, required this.onTap});
1194
+
1195
+ final bool dark;
1143
1196
  final VoidCallback onTap;
1144
- final bool? checked;
1145
- final String? trailingLabel;
1146
1197
 
1147
1198
  @override
1148
- State<_ToolsItem> createState() => _ToolsItemState();
1199
+ State<_ThemeCornerButton> createState() => _ThemeCornerButtonState();
1149
1200
  }
1150
1201
 
1151
- class _ToolsItemState extends State<_ToolsItem> {
1202
+ class _ThemeCornerButtonState extends State<_ThemeCornerButton> {
1152
1203
  bool _hovered = false;
1153
1204
 
1154
1205
  @override
1155
1206
  Widget build(BuildContext context) {
1207
+ // Light mode (sun): white fill, dark icon. Dark mode (moon): dark fill,
1208
+ // white icon. Both keep a subtle drop shadow to lift the button.
1209
+ final Color fill = widget.dark ? const Color(0xFF2C2C2E) : Colors.white;
1210
+ final Color iconColor = widget.dark
1211
+ ? Colors.white
1212
+ : const Color(0xFF1C1C1E);
1213
+ final Color hoverOverlay = widget.dark
1214
+ ? Colors.white.withValues(alpha: 0.12)
1215
+ : Colors.black.withValues(alpha: 0.08);
1216
+
1156
1217
  return MouseRegion(
1157
1218
  onEnter: (_) => setState(() => _hovered = true),
1158
1219
  onExit: (_) => setState(() => _hovered = false),
1159
1220
  cursor: SystemMouseCursors.click,
1160
1221
  child: GestureDetector(
1161
1222
  onTap: widget.onTap,
1162
- child: AnimatedContainer(
1163
- duration: const Duration(milliseconds: 120),
1164
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
1165
- color: _hovered
1166
- ? Colors.white.withValues(alpha: 0.10)
1167
- : Colors.transparent,
1168
- child: Row(
1169
- mainAxisSize: MainAxisSize.min,
1170
- children: [
1171
- Icon(widget.icon, color: Colors.white60, size: 14),
1172
- const SizedBox(width: 10),
1173
- Text(
1174
- widget.label,
1175
- style: const TextStyle(
1176
- color: Colors.white,
1177
- fontSize: 13,
1178
- fontWeight: FontWeight.w400,
1179
- ),
1223
+ child: DecoratedBox(
1224
+ decoration: BoxDecoration(
1225
+ shape: BoxShape.circle,
1226
+ color: fill,
1227
+ boxShadow: const [
1228
+ BoxShadow(
1229
+ color: Color(0x40000000),
1230
+ blurRadius: 10,
1231
+ offset: Offset(0, 3),
1180
1232
  ),
1181
- const SizedBox(width: 16),
1182
- if (widget.trailingLabel != null)
1183
- Text(
1184
- widget.trailingLabel!,
1185
- style: const TextStyle(
1186
- color: Colors.white38,
1187
- fontSize: 12,
1188
- fontWeight: FontWeight.w500,
1189
- ),
1190
- )
1191
- else
1192
- Icon(
1193
- Icons.check_rounded,
1194
- size: 14,
1195
- color: (widget.checked ?? false)
1196
- ? Colors.white
1197
- : Colors.transparent,
1198
- ),
1199
1233
  ],
1200
1234
  ),
1235
+ child: AnimatedContainer(
1236
+ duration: const Duration(milliseconds: 120),
1237
+ width: 28,
1238
+ height: 28,
1239
+ decoration: BoxDecoration(
1240
+ shape: BoxShape.circle,
1241
+ color: _hovered ? hoverOverlay : Colors.transparent,
1242
+ ),
1243
+ child: Icon(
1244
+ widget.dark ? KasyIcons.darkMode : KasyIcons.lightMode,
1245
+ color: iconColor,
1246
+ size: 15,
1247
+ ),
1248
+ ),
1201
1249
  ),
1202
1250
  ),
1203
1251
  );
@@ -1205,13 +1253,17 @@ class _ToolsItemState extends State<_ToolsItem> {
1205
1253
  }
1206
1254
 
1207
1255
  class _VerticalDivider extends StatelessWidget {
1256
+ const _VerticalDivider({this.base = Colors.white});
1257
+
1258
+ final Color base;
1259
+
1208
1260
  @override
1209
1261
  Widget build(BuildContext context) {
1210
1262
  return Container(
1211
1263
  width: 1,
1212
1264
  height: 18,
1213
1265
  margin: const EdgeInsets.symmetric(horizontal: 4),
1214
- color: Colors.white.withValues(alpha: 0.12),
1266
+ color: base.withValues(alpha: 0.12),
1215
1267
  );
1216
1268
  }
1217
1269
  }
@@ -1221,11 +1273,17 @@ class _IconBtn extends StatefulWidget {
1221
1273
  required this.icon,
1222
1274
  required this.onTap,
1223
1275
  this.color = Colors.white60,
1276
+ this.base = Colors.white,
1277
+ this.active = false,
1224
1278
  });
1225
1279
 
1226
1280
  final IconData icon;
1227
1281
  final VoidCallback onTap;
1228
1282
  final Color color;
1283
+ final Color base;
1284
+
1285
+ /// When true the button reads as "on": accent-tinted fill + accent icon.
1286
+ final bool active;
1229
1287
 
1230
1288
  @override
1231
1289
  State<_IconBtn> createState() => _IconBtnState();
@@ -1236,6 +1294,14 @@ class _IconBtnState extends State<_IconBtn> {
1236
1294
 
1237
1295
  @override
1238
1296
  Widget build(BuildContext context) {
1297
+ final bool on = widget.active;
1298
+ // Active: solid accent fill + white icon (filled-button contrast). Inactive:
1299
+ // solid foreground icon (no muted grays), background only on hover.
1300
+ final Color iconColor = on ? Colors.white : widget.color;
1301
+ final Color bg = on
1302
+ ? (_hovered ? _kAccent.withValues(alpha: 0.85) : _kAccent)
1303
+ : (_hovered ? widget.base.withValues(alpha: 0.12) : Colors.transparent);
1304
+
1239
1305
  return MouseRegion(
1240
1306
  onEnter: (_) => setState(() => _hovered = true),
1241
1307
  onExit: (_) => setState(() => _hovered = false),
@@ -1244,14 +1310,12 @@ class _IconBtnState extends State<_IconBtn> {
1244
1310
  onTap: widget.onTap,
1245
1311
  child: AnimatedContainer(
1246
1312
  duration: const Duration(milliseconds: 120),
1247
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
1313
+ padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
1248
1314
  decoration: BoxDecoration(
1249
- color: _hovered
1250
- ? Colors.white.withValues(alpha: 0.12)
1251
- : Colors.transparent,
1315
+ color: bg,
1252
1316
  borderRadius: BorderRadius.circular(8),
1253
1317
  ),
1254
- child: Icon(widget.icon, color: widget.color, size: 16),
1318
+ child: Icon(widget.icon, color: iconColor, size: 15),
1255
1319
  ),
1256
1320
  ),
1257
1321
  );