kasy-cli 1.37.0 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/lib/scaffold/CHANGELOG.json +9 -0
  2. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  3. package/lib/scaffold/backends/patch-base-hashes.json +4 -4
  4. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  5. package/package.json +1 -1
  6. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  7. package/templates/firebase/AGENTS.md +20 -10
  8. package/templates/firebase/DESIGN_SYSTEM.md +13 -0
  9. package/templates/firebase/README.en.md +1 -1
  10. package/templates/firebase/README.es.md +1 -1
  11. package/templates/firebase/README.md +1 -1
  12. package/templates/firebase/lib/components/kasy_app_bar.dart +57 -16
  13. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  14. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  15. package/templates/firebase/lib/components/kasy_sidebar.dart +397 -28
  16. package/templates/firebase/lib/components/kasy_web_header.dart +11 -3
  17. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +2 -213
  18. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  19. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +12 -4
  20. package/templates/firebase/lib/core/chrome/web_header_scope.dart +20 -0
  21. package/templates/firebase/lib/core/data/api/user_api.dart +4 -0
  22. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  23. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  24. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  25. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  26. package/templates/firebase/lib/core/states/user_state_notifier.dart +41 -0
  27. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  28. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  29. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  30. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  31. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  32. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +498 -466
  33. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  34. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -7
  35. package/templates/firebase/lib/features/home/home_components_page.dart +16 -6
  36. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +7 -0
  37. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +23 -13
  38. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -0
  39. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  40. package/templates/firebase/lib/features/settings/settings_page.dart +51 -38
  41. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +549 -376
  42. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  43. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +266 -141
  44. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +23 -30
  45. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +30 -49
  46. package/templates/firebase/lib/i18n/en.i18n.json +23 -9
  47. package/templates/firebase/lib/i18n/es.i18n.json +23 -9
  48. package/templates/firebase/lib/i18n/pt.i18n.json +23 -9
  49. package/templates/firebase/lib/router.dart +43 -25
  50. package/templates/firebase/pubspec.yaml +1 -1
  51. package/templates/firebase/test/admin_shell_chrome_test.dart +104 -0
  52. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  53. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -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,34 @@ 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
+ // Kit primary/accent (HeroUI blue). The chrome lives above the MaterialApp, so
36
+ // it can't read context.colors — mirror the design-system value here. Used to
37
+ // highlight active toggles so it's obvious at a glance what's turned on.
38
+ const Color _kAccent = Color(0xFF0485F7);
39
+
40
+ // ROADMAP: secondary controls (locale, landscape orientation, text-scale) are
41
+ // hidden for now — they'll move to a dedicated vertical toolbar later, while the
42
+ // main horizontal pill stays for quick access. Return true to bring them back
43
+ // inline. (A function, not a const, so the guarded widgets aren't dead code.)
44
+ bool _showSecondaryTools() => false;
45
+
46
+ // Inter — the kit's typeface. Centralizes the chrome's text styling so every
47
+ // label shares the design-system font.
48
+ TextStyle _chromeText({
49
+ required double size,
50
+ required FontWeight weight,
51
+ required Color color,
52
+ double spacing = 0,
53
+ }) => GoogleFonts.inter(
54
+ fontSize: size,
55
+ fontWeight: weight,
56
+ color: color,
57
+ letterSpacing: spacing,
58
+ );
30
59
 
31
60
  /// Keyboard shortcut for toggling the web device preview chrome, formatted
32
61
  /// for the current platform. Key names stay in English regardless of the
@@ -61,20 +90,6 @@ final List<DeviceInfo> _iPadDevices = [
61
90
  Devices.ios.iPadPro13InchesM4,
62
91
  ];
63
92
 
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
93
  class WebDevicePreview extends StatefulWidget {
79
94
  const WebDevicePreview({super.key, required this.child});
80
95
 
@@ -92,14 +107,18 @@ class WebDevicePreview extends StatefulWidget {
92
107
  class _WebDevicePreviewState extends State<WebDevicePreview>
93
108
  with WidgetsBindingObserver {
94
109
  int _platform = 0; // 0 = iOS, 1 = Android, 2 = iPad
95
- int _iosIndex = 1; // default: iPhone 16
110
+ int _iosIndex = 1; // default: iPhone 16
96
111
  int _androidIndex = 1; // default: Google Pixel 9
97
112
  int _iPadIndex = 0;
98
113
  bool _controlsVisible = false;
99
114
  Timer? _controlsTimer;
115
+ String? _toast;
116
+ bool _toastIsError = false;
117
+ Timer? _toastTimer;
100
118
 
101
- final ValueNotifier<DeviceInfo> _deviceNotifier =
102
- ValueNotifier<DeviceInfo>(_iosDevices[1]); // iPhone 16
119
+ final ValueNotifier<DeviceInfo> _deviceNotifier = ValueNotifier<DeviceInfo>(
120
+ _iosDevices[1],
121
+ ); // iPhone 16
103
122
  final ValueNotifier<bool> _frameVisibleNotifier = ValueNotifier<bool>(true);
104
123
  final ValueNotifier<bool> _bgDarkNotifier = ValueNotifier<bool>(false);
105
124
  final ValueNotifier<bool> _landscapeNotifier = ValueNotifier<bool>(false);
@@ -110,16 +129,16 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
110
129
  static const _textScaleSteps = [1.0, 1.3, 1.5];
111
130
 
112
131
  List<DeviceInfo> get _devices => switch (_platform) {
113
- 1 => _androidDevices,
114
- 2 => _iPadDevices,
115
- _ => _iosDevices,
116
- };
132
+ 1 => _androidDevices,
133
+ 2 => _iPadDevices,
134
+ _ => _iosDevices,
135
+ };
117
136
 
118
137
  int get _currentIndex => switch (_platform) {
119
- 1 => _androidIndex,
120
- 2 => _iPadIndex,
121
- _ => _iosIndex,
122
- };
138
+ 1 => _androidIndex,
139
+ 2 => _iPadIndex,
140
+ _ => _iosIndex,
141
+ };
123
142
 
124
143
  DeviceInfo get _currentDevice => _devices[_currentIndex];
125
144
 
@@ -132,6 +151,7 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
132
151
  );
133
152
  webDevicePreviewEnabledNotifier.addListener(_onEnabledChanged);
134
153
  _bgDarkNotifier.addListener(_onBgChanged);
154
+ devInspectorToastNotifier.addListener(_onInspectorToast);
135
155
  HardwareKeyboard.instance.addHandler(_handleKeyEvent);
136
156
  unawaited(_bootstrap());
137
157
  }
@@ -174,6 +194,7 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
174
194
  HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
175
195
  webDevicePreviewEnabledNotifier.removeListener(_onEnabledChanged);
176
196
  _bgDarkNotifier.removeListener(_onBgChanged);
197
+ devInspectorToastNotifier.removeListener(_onInspectorToast);
177
198
  _deviceNotifier.dispose();
178
199
  _frameVisibleNotifier.dispose();
179
200
  _bgDarkNotifier.dispose();
@@ -181,9 +202,31 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
181
202
  _textScaleNotifier.dispose();
182
203
  _localeNotifier.dispose();
183
204
  _controlsTimer?.cancel();
205
+ _toastTimer?.cancel();
184
206
  super.dispose();
185
207
  }
186
208
 
209
+ /// Transient confirmation pill shown in the chrome (e.g. "Image copied").
210
+ void _showToast(String message, {bool isError = false}) {
211
+ _toastTimer?.cancel();
212
+ setState(() {
213
+ _toast = message;
214
+ _toastIsError = isError;
215
+ });
216
+ _toastTimer = Timer(const Duration(milliseconds: 2200), () {
217
+ if (mounted) setState(() => _toast = null);
218
+ });
219
+ }
220
+
221
+ /// The DevInspector hands its "copied" message here so it renders in the same
222
+ /// spot and size as the chrome's own toasts (instead of inside the device).
223
+ void _onInspectorToast() {
224
+ final msg = devInspectorToastNotifier.value;
225
+ if (msg == null) return;
226
+ devInspectorToastNotifier.value = null;
227
+ if (mounted) _showToast(msg.message, isError: msg.isError);
228
+ }
229
+
187
230
  void _onEnabledChanged() {
188
231
  // Our chrome already surfaces inspector state via the pill, so suppress
189
232
  // the DevInspector's in-app status pill while the preview is on.
@@ -283,9 +326,16 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
283
326
  }
284
327
 
285
328
  void _setPlatform(int p) {
286
- setState(() => _platform = p);
329
+ setState(() {
330
+ _platform = p;
331
+ // Choosing a platform always lands on its first device:
332
+ // iPad → plain iPad, Android → Samsung Galaxy A50.
333
+ if (p == 1) _androidIndex = 0;
334
+ if (p == 2) _iPadIndex = 0;
335
+ });
287
336
  _deviceNotifier.value = _currentDevice;
288
337
  unawaited(_savePlatform());
338
+ if (p == 1 || p == 2) unawaited(_saveDeviceIndices());
289
339
  }
290
340
 
291
341
  void _toggleFrame() {
@@ -301,10 +351,6 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
301
351
  devInspectorActiveNotifier.value = !devInspectorActiveNotifier.value;
302
352
  }
303
353
 
304
- void _copyInspection() {
305
- devInspectorCopyTriggerNotifier.value = true;
306
- }
307
-
308
354
  void _toggleLandscape() {
309
355
  _landscapeNotifier.value = !_landscapeNotifier.value;
310
356
  unawaited(_saveLandscape());
@@ -330,44 +376,34 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
330
376
  }
331
377
 
332
378
  Future<void> _takeScreenshot() async {
333
- final boundary = _screenshotKey.currentContext?.findRenderObject()
334
- as RenderRepaintBoundary?;
379
+ final boundary =
380
+ _screenshotKey.currentContext?.findRenderObject()
381
+ as RenderRepaintBoundary?;
335
382
  if (boundary == null) return;
336
383
  final dpr =
337
- WidgetsBinding.instance.platformDispatcher.implicitView?.devicePixelRatio
338
- ?? 2.0;
384
+ WidgetsBinding
385
+ .instance
386
+ .platformDispatcher
387
+ .implicitView
388
+ ?.devicePixelRatio ??
389
+ 2.0;
339
390
  final image = await boundary.toImage(pixelRatio: dpr);
340
391
  final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
341
392
  if (byteData == null) return;
342
393
  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());
394
+ // Copy the PNG to the clipboard (with a download fallback). The web-only
395
+ // implementation lives behind a conditional import (png_clipboard.dart) so
396
+ // this file stays VM/native-compilable and never breaks `flutter test`.
397
+ final result = await copyOrDownloadPng(bytes);
398
+ if (!mounted) return;
399
+ switch (result) {
400
+ case PngExportResult.copied:
401
+ _showToast(t.webDevicePreview.imageCopied);
402
+ case PngExportResult.downloaded:
403
+ _showToast(t.webDevicePreview.imageDownloaded);
404
+ case PngExportResult.unavailable:
405
+ break;
406
+ }
371
407
  }
372
408
 
373
409
  void _next() {
@@ -389,15 +425,27 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
389
425
  Widget build(BuildContext context) {
390
426
  final enabled = webDevicePreviewEnabledNotifier.value;
391
427
 
428
+ // App theme (light/dark) lives in ThemeProvider, which sits ABOVE this
429
+ // widget — reading it here rebuilds the toolbar when the theme flips.
430
+ final AppTheme appTheme = ThemeProvider.of(context);
431
+ final bool appDark = appTheme.effectiveMode == ThemeMode.dark;
432
+
392
433
  // ThemeData.light() in Flutter 3.x M3 uses a lavender-tinted surface.
393
434
  // Passing backgroundColor directly avoids that and gives us an exact color.
394
- final canvasColor =
395
- _bgDarkNotifier.value ? const Color(0xFF1C1C1E) : Colors.white;
435
+ final canvasColor = _bgDarkNotifier.value
436
+ ? const Color(0xFF1C1C1E)
437
+ : Colors.white;
396
438
 
397
439
  final preview = DevicePreview(
398
440
  enabled: enabled,
399
441
  isToolbarVisible: false,
400
442
  backgroundColor: canvasColor,
443
+ // Reserve room at the top so the floating toolbar never sits on the device.
444
+ // DevicePreview fills the whole viewport, so this is the single source of
445
+ // background — there is no second surface that could create a seam.
446
+ padding: enabled
447
+ ? const EdgeInsets.only(top: 56, left: 20, right: 20, bottom: 20)
448
+ : null,
401
449
  storage: DevicePreviewStorage.none(),
402
450
  defaultDevice: _currentDevice,
403
451
  devices: [..._iosDevices, ..._androidDevices, ..._iPadDevices],
@@ -409,8 +457,8 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
409
457
  // DevicePreview hard-codes platformBrightness to light inside the
410
458
  // simulated frame, which breaks MaterialApp's ThemeMode.system.
411
459
  // Forward the real host brightness so "system" tracks the OS.
412
- platformBrightness: WidgetsBinding
413
- .instance.platformDispatcher.platformBrightness,
460
+ platformBrightness:
461
+ WidgetsBinding.instance.platformDispatcher.platformBrightness,
414
462
  ),
415
463
  child: _DeviceSwitchBridge(
416
464
  deviceNotifier: _deviceNotifier,
@@ -426,65 +474,108 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
426
474
 
427
475
  return TapRegionSurface(
428
476
  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(),
477
+ textDirection: TextDirection.ltr,
478
+ child: Stack(
479
+ children: [
480
+ // DevicePreview paints the entire viewport (single background, no
481
+ // seam). The device frame is pushed below the toolbar by the top
482
+ // padding above, so the toolbar never overlaps it.
483
+ RepaintBoundary(key: _screenshotKey, child: preview),
484
+ // Inspector highlight drawn ABOVE the device frame so widgets that
485
+ // hug the viewport edges (AppBar, bottom nav, …) keep a full border.
486
+ const Positioned.fill(
487
+ child: IgnorePointer(child: _DevInspectorExternalHighlight()),
440
488
  ),
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),
489
+ // Floating toolbar — overlaid at the top, centered. Opening its
490
+ // dropdowns never pushes or resizes the device (they overlay).
491
+ Positioned(
492
+ top: 12,
493
+ left: 0,
494
+ right: 0,
495
+ child: AnimatedOpacity(
496
+ opacity: _controlsVisible ? 1.0 : 0.0,
497
+ duration: const Duration(milliseconds: 400),
498
+ child: IgnorePointer(
499
+ ignoring: !_controlsVisible,
500
+ child: Center(
501
+ child: ListenableBuilder(
502
+ listenable: Listenable.merge([
503
+ _frameVisibleNotifier,
504
+ _bgDarkNotifier,
505
+ _landscapeNotifier,
506
+ _textScaleNotifier,
507
+ _localeNotifier,
508
+ devInspectorActiveNotifier,
509
+ ]),
510
+ builder: (context, child) => _PreviewControls(
511
+ platform: _platform,
512
+ deviceName: _currentDevice.name,
513
+ dark: _bgDarkNotifier.value,
514
+ appDark: appDark,
515
+ onToggleAppTheme: appTheme.toggle,
516
+ frameVisible: _frameVisibleNotifier.value,
517
+ isLandscape: _landscapeNotifier.value,
518
+ textScale: _textScaleNotifier.value,
519
+ currentLocale: _localeNotifier.value,
520
+ inspectorEnabled: devInspectorActiveNotifier.value,
521
+ onPlatformChanged: _setPlatform,
522
+ onNext: _next,
523
+ onToggleFrame: _toggleFrame,
524
+ onToggleLandscape: _toggleLandscape,
525
+ onCycleTextScale: _cycleTextScale,
526
+ onCycleLocale: _cycleLocale,
527
+ onToggleInspector: _toggleInspector,
528
+ onScreenshot: () => unawaited(_takeScreenshot()),
529
+ onClose: () => unawaited(_close()),
530
+ ),
531
+ ),
532
+ ),
533
+ ),
534
+ ),
535
+ ),
536
+ // Theme toggle — standalone sun/moon button pinned to the
537
+ // bottom-right corner, independent of the toolbar.
538
+ Positioned(
539
+ bottom: 10,
540
+ right: 10,
541
+ child: AnimatedOpacity(
542
+ opacity: _controlsVisible ? 1.0 : 0.0,
543
+ duration: const Duration(milliseconds: 400),
544
+ child: IgnorePointer(
545
+ ignoring: !_controlsVisible,
546
+ child: ListenableBuilder(
547
+ listenable: _bgDarkNotifier,
548
+ builder: (context, child) => _ThemeCornerButton(
549
+ dark: _bgDarkNotifier.value,
550
+ onTap: _toggleBg,
551
+ ),
552
+ ),
553
+ ),
554
+ ),
555
+ ),
556
+ // Transient confirmation toast (e.g. screenshot copied).
557
+ Positioned(
558
+ left: 0,
559
+ right: 0,
560
+ bottom: 24,
448
561
  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()),
562
+ child: Center(
563
+ child: AnimatedSwitcher(
564
+ duration: const Duration(milliseconds: 200),
565
+ child: _toast == null
566
+ ? const SizedBox.shrink()
567
+ : _Toast(
568
+ key: ValueKey(_toast),
569
+ message: _toast!,
570
+ isError: _toastIsError,
571
+ ),
480
572
  ),
481
573
  ),
482
574
  ),
483
575
  ),
484
- ),
485
- ],
576
+ ],
577
+ ),
486
578
  ),
487
- ),
488
579
  );
489
580
  }
490
581
  }
@@ -607,44 +698,45 @@ class _PreviewControls extends StatefulWidget {
607
698
  const _PreviewControls({
608
699
  required this.platform,
609
700
  required this.deviceName,
701
+ required this.dark,
702
+ required this.appDark,
703
+ required this.onToggleAppTheme,
610
704
  required this.frameVisible,
611
- required this.bgDark,
612
705
  required this.isLandscape,
613
706
  required this.textScale,
614
707
  required this.currentLocale,
615
708
  required this.inspectorEnabled,
616
709
  required this.onPlatformChanged,
617
- required this.onPrev,
618
710
  required this.onNext,
619
711
  required this.onToggleFrame,
620
- required this.onToggleBg,
621
712
  required this.onToggleLandscape,
622
713
  required this.onCycleTextScale,
623
714
  required this.onCycleLocale,
624
715
  required this.onToggleInspector,
625
- required this.onCopyInspection,
626
716
  required this.onScreenshot,
627
717
  required this.onClose,
628
718
  });
629
719
 
630
720
  final int platform;
631
721
  final String deviceName;
722
+ final bool dark;
723
+
724
+ /// App theme (light/dark) state + toggle — flips the real app ThemeMode.
725
+ final bool appDark;
726
+ final VoidCallback onToggleAppTheme;
727
+
632
728
  final bool frameVisible;
633
- final bool bgDark;
634
729
  final bool isLandscape;
635
730
  final double textScale;
636
731
  final AppLocale currentLocale;
637
732
  final bool inspectorEnabled;
638
733
  final ValueChanged<int> onPlatformChanged;
639
- final VoidCallback onPrev;
640
734
  final VoidCallback onNext;
641
735
  final VoidCallback onToggleFrame;
642
- final VoidCallback onToggleBg;
643
736
  final VoidCallback onToggleLandscape;
644
737
  final VoidCallback onCycleTextScale;
645
738
  final VoidCallback onCycleLocale;
646
739
  final VoidCallback onToggleInspector;
647
- final VoidCallback onCopyInspection;
648
740
  final VoidCallback onScreenshot;
649
741
  final VoidCallback onClose;
650
742
 
@@ -654,137 +746,60 @@ class _PreviewControls extends StatefulWidget {
654
746
 
655
747
  class _PreviewControlsState extends State<_PreviewControls> {
656
748
  bool _menuOpen = false;
657
- bool _toolsMenuOpen = false;
658
- bool _minimized = true;
659
749
  bool _platformBtnHovered = false;
660
- bool _toolsBtnHovered = false;
661
- bool _shadowVisible = true;
662
- Timer? _shadowTimer;
750
+ bool _deviceBtnHovered = false;
663
751
 
664
752
  static const _platformLabels = ['iOS', 'Android', 'iPad'];
665
753
 
666
- static const _pillDecoration = BoxDecoration(
667
- color: Color(0xFF2C2C2E),
668
- borderRadius: BorderRadius.all(Radius.circular(24)),
754
+ // Color scheme adapts to the preview theme: dark pill (white content) in dark
755
+ // mode, white pill (dark content) in light mode.
756
+ Color get _pillColor => widget.dark ? const Color(0xFF2C2C2E) : Colors.white;
757
+ Color get _menuColor => widget.dark ? const Color(0xFF3A3A3C) : Colors.white;
758
+ // Foreground (text/icons) base — alphas are derived from this.
759
+ Color get _fg => widget.dark ? Colors.white : const Color(0xFF1C1C1E);
760
+
761
+ BoxDecoration get _pillDecoration => BoxDecoration(
762
+ color: _pillColor,
763
+ borderRadius: const BorderRadius.all(Radius.circular(24)),
669
764
  );
670
765
 
766
+ // Tight, low-spread shadow: enough to lift the pill off the canvas without
767
+ // casting a wide smudge below it that reads as a horizontal "division" line.
671
768
  static const _pillShadow = [
672
- BoxShadow(
673
- color: Color(0x73000000),
674
- blurRadius: 20,
675
- offset: Offset(0, 6),
676
- ),
769
+ BoxShadow(color: Color(0x33000000), blurRadius: 8, offset: Offset(0, 2)),
677
770
  ];
678
771
 
679
772
  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
- });
773
+ 1.0 => '1×',
774
+ 1.3 => '1.3×',
775
+ _ => '1.5×',
776
+ };
689
777
 
690
- void _toggleToolsMenu() => setState(() {
691
- _toolsMenuOpen = !_toolsMenuOpen;
692
- if (_toolsMenuOpen) _menuOpen = false;
693
- });
778
+ void _toggleMenu() => setState(() => _menuOpen = !_menuOpen);
694
779
 
695
780
  void _selectPlatform(int i) {
696
781
  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();
782
+ setState(() => _menuOpen = false);
729
783
  }
730
784
 
731
785
  @override
732
786
  Widget build(BuildContext context) {
733
787
  return TapRegion(
734
788
  onTapOutside: (_) {
735
- if (_menuOpen || _toolsMenuOpen) {
736
- setState(() {
737
- _menuOpen = false;
738
- _toolsMenuOpen = false;
739
- });
740
- }
789
+ if (_menuOpen) setState(() => _menuOpen = false);
741
790
  },
742
791
  child: Column(
743
792
  crossAxisAlignment: CrossAxisAlignment.start,
744
793
  mainAxisSize: MainAxisSize.min,
745
794
  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
- ),
795
+ DecoratedBox(
796
+ decoration: const BoxDecoration(
797
+ borderRadius: BorderRadius.all(Radius.circular(24)),
798
+ boxShadow: _pillShadow,
760
799
  ),
800
+ child: _buildPill(),
761
801
  ),
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),
802
+ if (_menuOpen) _buildMenu(),
788
803
  ],
789
804
  ),
790
805
  );
@@ -792,7 +807,7 @@ class _PreviewControlsState extends State<_PreviewControls> {
792
807
 
793
808
  Widget _buildPill() {
794
809
  return Container(
795
- height: 40,
810
+ height: 34,
796
811
  padding: const EdgeInsets.symmetric(horizontal: 4),
797
812
  decoration: _pillDecoration,
798
813
  child: Row(
@@ -804,131 +819,144 @@ class _PreviewControlsState extends State<_PreviewControls> {
804
819
  onExit: (_) => setState(() => _platformBtnHovered = false),
805
820
  cursor: SystemMouseCursors.click,
806
821
  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,
822
+ onTap: _toggleMenu,
873
823
  child: AnimatedContainer(
874
824
  duration: const Duration(milliseconds: 160),
875
825
  margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
876
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
826
+ padding: const EdgeInsets.symmetric(
827
+ horizontal: 10,
828
+ vertical: 5,
829
+ ),
877
830
  decoration: BoxDecoration(
878
- color: Colors.white.withValues(
879
- alpha: _toolsMenuOpen ? 0.22 : (_toolsBtnHovered ? 0.20 : 0.14),
831
+ // No resting fill — it just alternates platforms. Subtle fill
832
+ // only while open or hovered.
833
+ color: _fg.withValues(
834
+ alpha: _menuOpen
835
+ ? 0.16
836
+ : (_platformBtnHovered ? 0.12 : 0.0),
880
837
  ),
881
838
  borderRadius: BorderRadius.circular(20),
882
839
  ),
883
840
  child: Row(
884
841
  mainAxisSize: MainAxisSize.min,
885
842
  children: [
886
- const Text(
887
- 'Tools',
888
- style: TextStyle(
889
- color: Colors.white,
890
- fontSize: 12,
891
- fontWeight: FontWeight.w600,
843
+ Text(
844
+ _platformLabels[widget.platform],
845
+ style: _chromeText(
846
+ size: 12,
847
+ weight: FontWeight.w600,
848
+ color: _fg,
892
849
  ),
893
850
  ),
894
851
  const SizedBox(width: 2),
895
852
  AnimatedRotation(
896
- turns: _toolsMenuOpen ? 0.5 : 0,
853
+ turns: _menuOpen ? 0.5 : 0,
897
854
  duration: const Duration(milliseconds: 160),
898
- child: const Icon(
899
- Icons.keyboard_arrow_down_rounded,
900
- color: Colors.white70,
901
- size: 13,
902
- ),
855
+ child: Icon(KasyIcons.chevronDown, color: _fg, size: 13),
903
856
  ),
904
857
  ],
905
858
  ),
906
859
  ),
907
860
  ),
908
861
  ),
909
- // Inspector group
910
- _VerticalDivider(),
862
+ _VerticalDivider(base: _fg),
863
+ // Tapping the name cycles to the next device — no side chevrons, saves
864
+ // space (same pattern as the locale cycler).
865
+ MouseRegion(
866
+ onEnter: (_) => setState(() => _deviceBtnHovered = true),
867
+ onExit: (_) => setState(() => _deviceBtnHovered = false),
868
+ cursor: SystemMouseCursors.click,
869
+ child: GestureDetector(
870
+ onTap: widget.onNext,
871
+ child: AnimatedContainer(
872
+ duration: const Duration(milliseconds: 160),
873
+ margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
874
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
875
+ decoration: BoxDecoration(
876
+ color: _fg.withValues(alpha: _deviceBtnHovered ? 0.12 : 0.0),
877
+ borderRadius: BorderRadius.circular(20),
878
+ ),
879
+ child: Text(
880
+ widget.deviceName,
881
+ style: _chromeText(
882
+ size: 12,
883
+ weight: FontWeight.w500,
884
+ color: _fg,
885
+ spacing: -0.2,
886
+ ),
887
+ ),
888
+ ),
889
+ ),
890
+ ),
891
+ // Locale chip — hidden for now (see ROADMAP: moves to vertical bar).
892
+ if (_showSecondaryTools()) ...[
893
+ _VerticalDivider(base: _fg),
894
+ _PillChip(
895
+ label: widget.currentLocale.languageCode.toUpperCase(),
896
+ onTap: widget.onCycleLocale,
897
+ base: _fg,
898
+ ),
899
+ ],
900
+ // Tools, all inline (no dropdown).
901
+ _VerticalDivider(base: _fg),
902
+ // Frame is a mode switch (with / without device frame), not an on/off
903
+ // toggle — the icon itself flips, so no accent highlight.
911
904
  _IconBtn(
912
- icon: Icons.touch_app_rounded,
913
- onTap: widget.onToggleInspector,
914
- color: widget.inspectorEnabled ? Colors.white : Colors.white30,
905
+ icon: widget.frameVisible
906
+ ? KasyIcons.deviceFrame
907
+ : KasyIcons.deviceFrameOff,
908
+ onTap: widget.onToggleFrame,
909
+ base: _fg,
910
+ color: _fg,
915
911
  ),
916
- if (widget.inspectorEnabled)
912
+ // App theme switch (light/dark of the app inside the device). Mode
913
+ // switch like frame — the sun/moon icon flips, so no accent fill.
914
+ _IconBtn(
915
+ icon: widget.appDark ? KasyIcons.darkMode : KasyIcons.lightMode,
916
+ onTap: widget.onToggleAppTheme,
917
+ base: _fg,
918
+ color: _fg,
919
+ ),
920
+ // Orientation + text-scale hidden for now (see ROADMAP flag).
921
+ if (_showSecondaryTools()) ...[
917
922
  _IconBtn(
918
- icon: Icons.content_copy_rounded,
919
- onTap: widget.onCopyInspection,
923
+ icon: widget.isLandscape
924
+ ? KasyIcons.landscape
925
+ : KasyIcons.portrait,
926
+ onTap: widget.onToggleLandscape,
927
+ base: _fg,
928
+ active: widget.isLandscape,
929
+ color: _fg,
930
+ ),
931
+ _PillChip(
932
+ label: _textScaleLabel(widget.textScale),
933
+ onTap: widget.onCycleTextScale,
934
+ base: _fg,
935
+ active: widget.textScale != 1.0,
920
936
  ),
921
- // Utilities: minimize · close
922
- _VerticalDivider(),
937
+ ],
938
+ _IconBtn(
939
+ icon: KasyIcons.cameraAlt,
940
+ onTap: widget.onScreenshot,
941
+ base: _fg,
942
+ color: _fg,
943
+ ),
944
+ // Inspector group
945
+ _VerticalDivider(base: _fg),
923
946
  _IconBtn(
924
- icon: Icons.unfold_less_rounded,
925
- onTap: _minimize,
926
- color: Colors.white30,
947
+ icon: KasyIcons.inspector,
948
+ onTap: widget.onToggleInspector,
949
+ base: _fg,
950
+ active: widget.inspectorEnabled,
951
+ color: _fg,
927
952
  ),
953
+ // Close
954
+ _VerticalDivider(base: _fg),
928
955
  _IconBtn(
929
- icon: Icons.close_rounded,
956
+ icon: KasyIcons.close,
930
957
  onTap: widget.onClose,
931
- color: Colors.white54,
958
+ base: _fg,
959
+ color: _fg,
932
960
  ),
933
961
  const SizedBox(width: 2),
934
962
  ],
@@ -941,11 +969,11 @@ class _PreviewControlsState extends State<_PreviewControls> {
941
969
  margin: const EdgeInsets.only(top: 6),
942
970
  padding: const EdgeInsets.symmetric(vertical: 4),
943
971
  decoration: BoxDecoration(
944
- color: const Color(0xFF3A3A3C),
972
+ color: _menuColor,
945
973
  borderRadius: BorderRadius.circular(12),
946
974
  boxShadow: [
947
975
  BoxShadow(
948
- color: Colors.black.withValues(alpha: 0.4),
976
+ color: Colors.black.withValues(alpha: widget.dark ? 0.4 : 0.18),
949
977
  blurRadius: 16,
950
978
  offset: const Offset(0, 4),
951
979
  ),
@@ -960,6 +988,7 @@ class _PreviewControlsState extends State<_PreviewControls> {
960
988
  return _DropdownItem(
961
989
  label: _platformLabels[i],
962
990
  selected: selected,
991
+ base: _fg,
963
992
  onTap: () => _selectPlatform(i),
964
993
  );
965
994
  }),
@@ -967,72 +996,6 @@ class _PreviewControlsState extends State<_PreviewControls> {
967
996
  ),
968
997
  );
969
998
  }
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
999
  }
1037
1000
 
1038
1001
  class _DropdownItem extends StatefulWidget {
@@ -1040,11 +1003,13 @@ class _DropdownItem extends StatefulWidget {
1040
1003
  required this.label,
1041
1004
  required this.selected,
1042
1005
  required this.onTap,
1006
+ this.base = Colors.white,
1043
1007
  });
1044
1008
 
1045
1009
  final String label;
1046
1010
  final bool selected;
1047
1011
  final VoidCallback onTap;
1012
+ final Color base;
1048
1013
 
1049
1014
  @override
1050
1015
  State<_DropdownItem> createState() => _DropdownItemState();
@@ -1065,15 +1030,16 @@ class _DropdownItemState extends State<_DropdownItem> {
1065
1030
  duration: const Duration(milliseconds: 120),
1066
1031
  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
1067
1032
  color: _hovered
1068
- ? Colors.white.withValues(alpha: 0.10)
1033
+ ? widget.base.withValues(alpha: 0.10)
1069
1034
  : Colors.transparent,
1070
1035
  child: Text(
1071
1036
  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,
1037
+ style: _chromeText(
1038
+ size: 13,
1039
+ weight: widget.selected ? FontWeight.w600 : FontWeight.w400,
1040
+ color: widget.selected
1041
+ ? widget.base
1042
+ : widget.base.withValues(alpha: 0.5),
1077
1043
  ),
1078
1044
  ),
1079
1045
  ),
@@ -1086,10 +1052,16 @@ class _PillChip extends StatefulWidget {
1086
1052
  const _PillChip({
1087
1053
  required this.label,
1088
1054
  required this.onTap,
1055
+ this.base = Colors.white,
1056
+ this.active = false,
1089
1057
  });
1090
1058
 
1091
1059
  final String label;
1092
1060
  final VoidCallback onTap;
1061
+ final Color base;
1062
+
1063
+ /// When true the chip reads as "non-default" (accent tint + accent text).
1064
+ final bool active;
1093
1065
 
1094
1066
  @override
1095
1067
  State<_PillChip> createState() => _PillChipState();
@@ -1100,6 +1072,14 @@ class _PillChipState extends State<_PillChip> {
1100
1072
 
1101
1073
  @override
1102
1074
  Widget build(BuildContext context) {
1075
+ final bool on = widget.active;
1076
+ final Color textColor = on ? Colors.white : widget.base;
1077
+ // No resting fill — it just cycles a value. Solid accent when active,
1078
+ // subtle fill only on hover otherwise.
1079
+ final Color bg = on
1080
+ ? (_hovered ? _kAccent.withValues(alpha: 0.85) : _kAccent)
1081
+ : (_hovered ? widget.base.withValues(alpha: 0.14) : Colors.transparent);
1082
+
1103
1083
  return MouseRegion(
1104
1084
  onEnter: (_) => setState(() => _hovered = true),
1105
1085
  onExit: (_) => setState(() => _hovered = false),
@@ -1111,16 +1091,16 @@ class _PillChipState extends State<_PillChip> {
1111
1091
  margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
1112
1092
  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
1113
1093
  decoration: BoxDecoration(
1114
- color: Colors.white.withValues(alpha: _hovered ? 0.22 : 0.14),
1094
+ color: bg,
1115
1095
  borderRadius: BorderRadius.circular(20),
1116
1096
  ),
1117
1097
  child: Text(
1118
1098
  widget.label,
1119
- style: const TextStyle(
1120
- color: Colors.white,
1121
- fontSize: 11,
1122
- fontWeight: FontWeight.w600,
1123
- letterSpacing: 0.3,
1099
+ style: _chromeText(
1100
+ size: 11,
1101
+ weight: FontWeight.w600,
1102
+ color: textColor,
1103
+ spacing: 0.3,
1124
1104
  ),
1125
1105
  ),
1126
1106
  ),
@@ -1129,75 +1109,111 @@ class _PillChipState extends State<_PillChip> {
1129
1109
  }
1130
1110
  }
1131
1111
 
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
- });
1112
+ /// Small confirmation pill shown briefly in the chrome (e.g. after a screenshot
1113
+ /// is copied). Dark, frosted, centered near the bottom.
1114
+ class _Toast extends StatelessWidget {
1115
+ const _Toast({super.key, required this.message, this.isError = false});
1140
1116
 
1141
- final IconData icon;
1142
- final String label;
1117
+ final String message;
1118
+ final bool isError;
1119
+
1120
+ @override
1121
+ Widget build(BuildContext context) {
1122
+ return DecoratedBox(
1123
+ decoration: BoxDecoration(
1124
+ color: const Color(0xF02C2C2E),
1125
+ borderRadius: BorderRadius.circular(20),
1126
+ boxShadow: const [
1127
+ BoxShadow(color: Color(0x40000000), blurRadius: 12, offset: Offset(0, 3)),
1128
+ ],
1129
+ ),
1130
+ child: Padding(
1131
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
1132
+ child: Row(
1133
+ mainAxisSize: MainAxisSize.min,
1134
+ children: [
1135
+ Icon(
1136
+ isError ? KasyIcons.error : KasyIcons.checkCircle,
1137
+ color: isError
1138
+ ? const Color(0xFFFF453A)
1139
+ : const Color(0xFF34C759),
1140
+ size: 15,
1141
+ ),
1142
+ const SizedBox(width: 8),
1143
+ Text(
1144
+ message,
1145
+ style: _chromeText(
1146
+ size: 12,
1147
+ weight: FontWeight.w500,
1148
+ color: Colors.white,
1149
+ ),
1150
+ ),
1151
+ ],
1152
+ ),
1153
+ ),
1154
+ );
1155
+ }
1156
+ }
1157
+
1158
+ /// Standalone sun/moon theme toggle pinned to the top-right corner. Switches
1159
+ /// the preview canvas between light (sun) and dark (moon).
1160
+ class _ThemeCornerButton extends StatefulWidget {
1161
+ const _ThemeCornerButton({required this.dark, required this.onTap});
1162
+
1163
+ final bool dark;
1143
1164
  final VoidCallback onTap;
1144
- final bool? checked;
1145
- final String? trailingLabel;
1146
1165
 
1147
1166
  @override
1148
- State<_ToolsItem> createState() => _ToolsItemState();
1167
+ State<_ThemeCornerButton> createState() => _ThemeCornerButtonState();
1149
1168
  }
1150
1169
 
1151
- class _ToolsItemState extends State<_ToolsItem> {
1170
+ class _ThemeCornerButtonState extends State<_ThemeCornerButton> {
1152
1171
  bool _hovered = false;
1153
1172
 
1154
1173
  @override
1155
1174
  Widget build(BuildContext context) {
1175
+ // Light mode (sun): white fill, dark icon. Dark mode (moon): dark fill,
1176
+ // white icon. Both keep a subtle drop shadow to lift the button.
1177
+ final Color fill = widget.dark ? const Color(0xFF2C2C2E) : Colors.white;
1178
+ final Color iconColor = widget.dark
1179
+ ? Colors.white
1180
+ : const Color(0xFF1C1C1E);
1181
+ final Color hoverOverlay = widget.dark
1182
+ ? Colors.white.withValues(alpha: 0.12)
1183
+ : Colors.black.withValues(alpha: 0.08);
1184
+
1156
1185
  return MouseRegion(
1157
1186
  onEnter: (_) => setState(() => _hovered = true),
1158
1187
  onExit: (_) => setState(() => _hovered = false),
1159
1188
  cursor: SystemMouseCursors.click,
1160
1189
  child: GestureDetector(
1161
1190
  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
- ),
1191
+ child: DecoratedBox(
1192
+ decoration: BoxDecoration(
1193
+ shape: BoxShape.circle,
1194
+ color: fill,
1195
+ boxShadow: const [
1196
+ BoxShadow(
1197
+ color: Color(0x40000000),
1198
+ blurRadius: 10,
1199
+ offset: Offset(0, 3),
1180
1200
  ),
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
1201
  ],
1200
1202
  ),
1203
+ child: AnimatedContainer(
1204
+ duration: const Duration(milliseconds: 120),
1205
+ width: 28,
1206
+ height: 28,
1207
+ decoration: BoxDecoration(
1208
+ shape: BoxShape.circle,
1209
+ color: _hovered ? hoverOverlay : Colors.transparent,
1210
+ ),
1211
+ child: Icon(
1212
+ widget.dark ? KasyIcons.darkMode : KasyIcons.lightMode,
1213
+ color: iconColor,
1214
+ size: 15,
1215
+ ),
1216
+ ),
1201
1217
  ),
1202
1218
  ),
1203
1219
  );
@@ -1205,13 +1221,17 @@ class _ToolsItemState extends State<_ToolsItem> {
1205
1221
  }
1206
1222
 
1207
1223
  class _VerticalDivider extends StatelessWidget {
1224
+ const _VerticalDivider({this.base = Colors.white});
1225
+
1226
+ final Color base;
1227
+
1208
1228
  @override
1209
1229
  Widget build(BuildContext context) {
1210
1230
  return Container(
1211
1231
  width: 1,
1212
1232
  height: 18,
1213
1233
  margin: const EdgeInsets.symmetric(horizontal: 4),
1214
- color: Colors.white.withValues(alpha: 0.12),
1234
+ color: base.withValues(alpha: 0.12),
1215
1235
  );
1216
1236
  }
1217
1237
  }
@@ -1221,11 +1241,17 @@ class _IconBtn extends StatefulWidget {
1221
1241
  required this.icon,
1222
1242
  required this.onTap,
1223
1243
  this.color = Colors.white60,
1244
+ this.base = Colors.white,
1245
+ this.active = false,
1224
1246
  });
1225
1247
 
1226
1248
  final IconData icon;
1227
1249
  final VoidCallback onTap;
1228
1250
  final Color color;
1251
+ final Color base;
1252
+
1253
+ /// When true the button reads as "on": accent-tinted fill + accent icon.
1254
+ final bool active;
1229
1255
 
1230
1256
  @override
1231
1257
  State<_IconBtn> createState() => _IconBtnState();
@@ -1236,6 +1262,14 @@ class _IconBtnState extends State<_IconBtn> {
1236
1262
 
1237
1263
  @override
1238
1264
  Widget build(BuildContext context) {
1265
+ final bool on = widget.active;
1266
+ // Active: solid accent fill + white icon (filled-button contrast). Inactive:
1267
+ // solid foreground icon (no muted grays), background only on hover.
1268
+ final Color iconColor = on ? Colors.white : widget.color;
1269
+ final Color bg = on
1270
+ ? (_hovered ? _kAccent.withValues(alpha: 0.85) : _kAccent)
1271
+ : (_hovered ? widget.base.withValues(alpha: 0.12) : Colors.transparent);
1272
+
1239
1273
  return MouseRegion(
1240
1274
  onEnter: (_) => setState(() => _hovered = true),
1241
1275
  onExit: (_) => setState(() => _hovered = false),
@@ -1244,14 +1278,12 @@ class _IconBtnState extends State<_IconBtn> {
1244
1278
  onTap: widget.onTap,
1245
1279
  child: AnimatedContainer(
1246
1280
  duration: const Duration(milliseconds: 120),
1247
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
1281
+ padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
1248
1282
  decoration: BoxDecoration(
1249
- color: _hovered
1250
- ? Colors.white.withValues(alpha: 0.12)
1251
- : Colors.transparent,
1283
+ color: bg,
1252
1284
  borderRadius: BorderRadius.circular(8),
1253
1285
  ),
1254
- child: Icon(widget.icon, color: widget.color, size: 16),
1286
+ child: Icon(widget.icon, color: iconColor, size: 15),
1255
1287
  ),
1256
1288
  ),
1257
1289
  );