kasy-cli 1.39.1 → 1.40.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 (21) hide show
  1. package/lib/scaffold/CHANGELOG.json +14 -0
  2. package/package.json +1 -1
  3. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +27 -27
  4. package/templates/firebase/lib/components/components.dart +2 -0
  5. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +25 -7
  6. package/templates/firebase/lib/components/kasy_menu.dart +902 -0
  7. package/templates/firebase/lib/components/kasy_popover.dart +267 -0
  8. package/templates/firebase/lib/components/kasy_sidebar.dart +20 -10
  9. package/templates/firebase/lib/core/navigation/kasy_route_observer.dart +8 -0
  10. package/templates/firebase/lib/core/theme/texts.dart +25 -0
  11. package/templates/firebase/lib/features/home/home_components_page.dart +23 -4
  12. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +320 -0
  13. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +38 -94
  14. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +32 -107
  15. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +21 -21
  16. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +35 -27
  17. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +22 -17
  18. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +12 -7
  19. package/templates/firebase/lib/router.dart +2 -0
  20. package/templates/firebase/pubspec.yaml +1 -1
  21. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +0 -81
@@ -332,6 +332,21 @@ ComponentPreviewDefinition? getComponentPreviewDefinition(
332
332
  ),
333
333
  ],
334
334
  );
335
+ case 'Menu':
336
+ return const ComponentPreviewDefinition(
337
+ title: 'Menu',
338
+ variants: [
339
+ ComponentPreviewVariant(
340
+ label: 'Basic usage',
341
+ builder: _buildMenuBasicUsage,
342
+ ),
343
+ ComponentPreviewVariant(
344
+ label: 'Selection',
345
+ builder: _buildMenuStyles,
346
+ ),
347
+ ComponentPreviewVariant(label: 'Submenu', builder: _buildMenuSubmenu),
348
+ ],
349
+ );
335
350
  case 'Badge':
336
351
  return const ComponentPreviewDefinition(
337
352
  title: 'Badge',
@@ -5417,6 +5432,311 @@ Widget _previewIntrinsicLaunch(Widget child) {
5417
5432
  );
5418
5433
  }
5419
5434
 
5435
+ // ── Menu (KasyMenu) previews ─────────────────────────────────────────────────
5436
+ // Each preset is triggered by a button — a real menu opens, anchored to it.
5437
+ // Most open as the default floating menu (popover, both platforms); one opens as
5438
+ // a bottom sheet to show the opt-in presentation.
5439
+
5440
+ // Basic usage menu — mirrors the HeroUI "Actions / Danger zone" dropdown
5441
+ // (Figma 17840:22571): grouped rows with an icon, title + description and a
5442
+ // keyboard shortcut, plus a destructive "Danger zone". The kit has no pencil
5443
+ // glyph yet, so Edit file borrows the note icon.
5444
+ const List<KasyMenuSection> _kMenuActions = [
5445
+ KasyMenuSection(
5446
+ label: 'Actions',
5447
+ items: [
5448
+ KasyMenuItem(
5449
+ icon: KasyIcons.add,
5450
+ title: 'New file',
5451
+ description: 'Create a new file',
5452
+ shortcut: '⌘ N',
5453
+ onTap: _triggerTapFeedback,
5454
+ ),
5455
+ KasyMenuItem(
5456
+ icon: KasyIcons.copy,
5457
+ title: 'Copy link',
5458
+ description: 'Copy the file link',
5459
+ shortcut: '⌘ L',
5460
+ onTap: _triggerTapFeedback,
5461
+ ),
5462
+ KasyMenuItem(
5463
+ icon: KasyIcons.note,
5464
+ title: 'Edit file',
5465
+ description: 'Make changes',
5466
+ shortcut: '⌘ E',
5467
+ onTap: _triggerTapFeedback,
5468
+ ),
5469
+ ],
5470
+ ),
5471
+ KasyMenuSection(
5472
+ label: 'Danger zone',
5473
+ items: [
5474
+ KasyMenuItem(
5475
+ icon: KasyIcons.trash,
5476
+ title: 'Delete file',
5477
+ description: 'Move to trash',
5478
+ shortcut: '⌘ ⇧ K',
5479
+ tone: KasyMenuItemTone.danger,
5480
+ onTap: _triggerTapFeedback,
5481
+ ),
5482
+ ],
5483
+ ),
5484
+ ];
5485
+
5486
+ const List<KasyMenuSection> _kMenuSubmenu = [
5487
+ KasyMenuSection(
5488
+ items: [
5489
+ KasyMenuItem(
5490
+ icon: KasyIcons.share,
5491
+ title: 'Share',
5492
+ submenu: [
5493
+ KasyMenuSection(
5494
+ items: [
5495
+ KasyMenuItem(title: 'WhatsApp', onTap: _triggerTapFeedback),
5496
+ KasyMenuItem(title: 'Telegram', onTap: _triggerTapFeedback),
5497
+ KasyMenuItem(title: 'Email', onTap: _triggerTapFeedback),
5498
+ ],
5499
+ ),
5500
+ ],
5501
+ ),
5502
+ KasyMenuItem(
5503
+ icon: KasyIcons.copy,
5504
+ title: 'Copy link',
5505
+ onTap: _triggerTapFeedback,
5506
+ ),
5507
+ ],
5508
+ ),
5509
+ ];
5510
+
5511
+
5512
+ // Layout shared by the two interactive menu demos: the trigger floats centered
5513
+ // in the open area (so the menu has room to anchor against it) and a toggle is
5514
+ // pinned at the bottom, just above the variant title. Height is a slice of the
5515
+ // viewport, clamped, and kept under the preview height so it still centers.
5516
+ class _MenuDemoFrame extends StatelessWidget {
5517
+ final Widget trigger;
5518
+ final String controlTitle;
5519
+ final String controlSubtitle;
5520
+ final bool controlValue;
5521
+ final ValueChanged<bool> onControlChanged;
5522
+
5523
+ const _MenuDemoFrame({
5524
+ required this.trigger,
5525
+ required this.controlTitle,
5526
+ required this.controlSubtitle,
5527
+ required this.controlValue,
5528
+ required this.onControlChanged,
5529
+ });
5530
+
5531
+ @override
5532
+ Widget build(BuildContext context) {
5533
+ // Give the demo real vertical room so the control row can sit near the
5534
+ // bottom (like a settings row) instead of crowding under the trigger. The
5535
+ // trigger still lives in a plain centering Row — the menu anchors to its
5536
+ // render box via LayerLink, so the surrounding height never moves the menu.
5537
+ final double viewportHeight = MediaQuery.sizeOf(context).height;
5538
+ final double frameHeight = (viewportHeight - 260).clamp(320.0, 520.0);
5539
+ return SizedBox(
5540
+ height: frameHeight,
5541
+ child: Column(
5542
+ children: [
5543
+ const Spacer(flex: 4),
5544
+ Row(mainAxisAlignment: MainAxisAlignment.center, children: [trigger]),
5545
+ const Spacer(flex: 6),
5546
+ Row(
5547
+ children: [
5548
+ Expanded(
5549
+ child: Column(
5550
+ crossAxisAlignment: CrossAxisAlignment.start,
5551
+ mainAxisSize: MainAxisSize.min,
5552
+ children: [
5553
+ Text(
5554
+ controlTitle,
5555
+ style: context.textTheme.titleSmall?.copyWith(
5556
+ color: context.colors.onSurface,
5557
+ ),
5558
+ ),
5559
+ Text(
5560
+ controlSubtitle,
5561
+ style: context.textTheme.bodySmall?.copyWith(
5562
+ color: context.colors.muted,
5563
+ ),
5564
+ ),
5565
+ ],
5566
+ ),
5567
+ ),
5568
+ // The same switch the Settings screen uses: adaptive (Cupertino on
5569
+ // iOS, Material elsewhere — identical look per platform), scaled
5570
+ // down, themed to the primary color.
5571
+ Transform.scale(
5572
+ scale: 0.82,
5573
+ child: Switch.adaptive(
5574
+ value: controlValue,
5575
+ onChanged: onControlChanged,
5576
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
5577
+ ),
5578
+ ),
5579
+ ],
5580
+ ),
5581
+ ],
5582
+ ),
5583
+ );
5584
+ }
5585
+ }
5586
+
5587
+ Widget _buildMenuBasicUsage(BuildContext context) => const _MenuBasicUsageDemo();
5588
+
5589
+ // First preview: the trigger + a "Bottom Sheet" toggle. Off → the menu opens as
5590
+ // a popover anchored to the button; on → it slides up as a bottom sheet. The
5591
+ // same compact menu either way — only the container changes.
5592
+ class _MenuBasicUsageDemo extends StatefulWidget {
5593
+ const _MenuBasicUsageDemo();
5594
+
5595
+ @override
5596
+ State<_MenuBasicUsageDemo> createState() => _MenuBasicUsageDemoState();
5597
+ }
5598
+
5599
+ class _MenuBasicUsageDemoState extends State<_MenuBasicUsageDemo> {
5600
+ bool _bottomSheet = false;
5601
+
5602
+ @override
5603
+ Widget build(BuildContext context) {
5604
+ return _MenuDemoFrame(
5605
+ controlTitle: 'Bottom Sheet',
5606
+ controlSubtitle: 'Toggle bottom sheet presentation',
5607
+ controlValue: _bottomSheet,
5608
+ onControlChanged: (bool v) => setState(() => _bottomSheet = v),
5609
+ trigger: KasyMenuAnchor(
5610
+ sections: _kMenuActions,
5611
+ // Wider panel: rows carry a title + description (HeroUI menu).
5612
+ width: 288,
5613
+ presentation: _bottomSheet
5614
+ ? KasyMenuPresentation.bottomSheet
5615
+ : KasyMenuPresentation.popover,
5616
+ builder: (BuildContext context, VoidCallback open) => KasyButton(
5617
+ label: 'Actions',
5618
+ variant: KasyButtonVariant.soft,
5619
+ onPressed: () {
5620
+ _triggerTapFeedback();
5621
+ open();
5622
+ },
5623
+ ),
5624
+ ),
5625
+ );
5626
+ }
5627
+ }
5628
+
5629
+ Widget _buildMenuStyles(BuildContext context) => const _MenuSelectionDemo();
5630
+
5631
+ // Second preview: a real, interactive selection menu. Tapping a "Text Style"
5632
+ // row toggles its check (multi-select); tapping a "Text Alignment" row moves the
5633
+ // filled dot (single-select). The selection persists across reopens. A "Should
5634
+ // Close On Select" toggle controls whether picking an item dismisses the menu:
5635
+ // off → keep picking, tap outside to close (HeroUI editor menu); on → one pick
5636
+ // closes it.
5637
+ class _MenuSelectionDemo extends StatefulWidget {
5638
+ const _MenuSelectionDemo();
5639
+
5640
+ @override
5641
+ State<_MenuSelectionDemo> createState() => _MenuSelectionDemoState();
5642
+ }
5643
+
5644
+ class _MenuSelectionDemoState extends State<_MenuSelectionDemo> {
5645
+ // Multi-select set + single-select value, kept on the demo so reopening the
5646
+ // menu shows the last selection.
5647
+ final Set<String> _styles = <String>{'Bold', 'Underline'};
5648
+ String _align = 'Right';
5649
+ bool _closeOnSelect = false;
5650
+
5651
+ static const List<(String, String)> _styleItems = [
5652
+ ('Bold', '⌘ B'),
5653
+ ('Italic', '⌘ I'),
5654
+ ('Underline', '⌘ U'),
5655
+ ];
5656
+ static const List<(String, String)> _alignItems = [
5657
+ ('Left', '⌥ A'),
5658
+ ('Center', '⌥ H'),
5659
+ ('Right', '⌥ D'),
5660
+ ];
5661
+
5662
+ void _toggleStyle(String title) {
5663
+ setState(() {
5664
+ if (!_styles.remove(title)) _styles.add(title);
5665
+ });
5666
+ }
5667
+
5668
+ void _selectAlign(String title) => setState(() => _align = title);
5669
+
5670
+ // Built from state so each tap (via setState) rebuilds the anchor and the
5671
+ // open menu reflects the new selection in place.
5672
+ List<KasyMenuSection> _buildSections() => [
5673
+ KasyMenuSection(
5674
+ label: 'Text Style',
5675
+ selectionMode: KasyMenuSelectionMode.multiple,
5676
+ items: [
5677
+ for (final (String title, String shortcut) in _styleItems)
5678
+ KasyMenuItem(
5679
+ title: title,
5680
+ shortcut: shortcut,
5681
+ selected: _styles.contains(title),
5682
+ onTap: () => _toggleStyle(title),
5683
+ ),
5684
+ ],
5685
+ ),
5686
+ KasyMenuSection(
5687
+ label: 'Text Alignment',
5688
+ selectionMode: KasyMenuSelectionMode.single,
5689
+ items: [
5690
+ for (final (String title, String shortcut) in _alignItems)
5691
+ KasyMenuItem(
5692
+ title: title,
5693
+ shortcut: shortcut,
5694
+ selected: _align == title,
5695
+ onTap: () => _selectAlign(title),
5696
+ ),
5697
+ ],
5698
+ ),
5699
+ ];
5700
+
5701
+ @override
5702
+ Widget build(BuildContext context) {
5703
+ return _MenuDemoFrame(
5704
+ controlTitle: 'Should Close On Select',
5705
+ controlSubtitle: 'Toggle should close on select',
5706
+ controlValue: _closeOnSelect,
5707
+ onControlChanged: (bool v) => setState(() => _closeOnSelect = v),
5708
+ trigger: KasyMenuAnchor(
5709
+ sections: _buildSections(),
5710
+ closeOnSelect: _closeOnSelect,
5711
+ builder: (BuildContext context, VoidCallback open) => KasyButton(
5712
+ label: 'Text styles',
5713
+ variant: KasyButtonVariant.soft,
5714
+ onPressed: () {
5715
+ _triggerTapFeedback();
5716
+ open();
5717
+ },
5718
+ ),
5719
+ ),
5720
+ );
5721
+ }
5722
+ }
5723
+
5724
+ Widget _buildMenuSubmenu(BuildContext context) {
5725
+ return _previewIntrinsicLaunch(
5726
+ KasyMenuAnchor(
5727
+ sections: _kMenuSubmenu,
5728
+ builder: (BuildContext context, VoidCallback open) => KasyButton(
5729
+ label: 'Share',
5730
+ variant: KasyButtonVariant.soft,
5731
+ onPressed: () {
5732
+ _triggerTapFeedback();
5733
+ open();
5734
+ },
5735
+ ),
5736
+ ),
5737
+ );
5738
+ }
5739
+
5420
5740
  const List<KasyAccordionItemData> _kAccordionItems = <KasyAccordionItemData>[
5421
5741
  KasyAccordionItemData(
5422
5742
  icon: KasyIcons.send,
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
5
5
  import 'package:flutter_riverpod/flutter_riverpod.dart';
6
6
  import 'package:image_picker/image_picker.dart';
7
7
  import 'package:kasy_kit/components/kasy_avatar.dart';
8
- import 'package:kasy_kit/components/kasy_bottom_sheet.dart';
8
+ import 'package:kasy_kit/components/kasy_menu.dart';
9
9
  import 'package:kasy_kit/core/data/entities/upload_result.dart';
10
10
  import 'package:kasy_kit/core/data/models/user.dart';
11
11
  import 'package:kasy_kit/core/data/repositories/user_repository.dart';
@@ -13,7 +13,6 @@ import 'package:kasy_kit/core/states/models/user_state.dart';
13
13
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
14
14
  import 'package:kasy_kit/core/theme/theme.dart';
15
15
  import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
16
- import 'package:kasy_kit/core/widgets/kasy_hover.dart';
17
16
  import 'package:kasy_kit/features/settings/ui/widgets/avatar_utils.dart';
18
17
  import 'package:kasy_kit/features/settings/ui/widgets/round_progress.dart';
19
18
  import 'package:kasy_kit/i18n/translations.g.dart';
@@ -121,69 +120,56 @@ class _EditableUserAvatarState extends ConsumerState<EditableUserAvatar> {
121
120
  BuildContext context,
122
121
  UserState userState,
123
122
  String? avatarPath,
124
- ) async {
123
+ ) {
125
124
  final userId = userState.user.idOrNull;
126
125
  final cachedUrl = userId == null ? null : getCachedAvatarUrl(userId);
127
126
  final hasAvatar =
128
127
  _hasAvatarUrl(avatarPath) ||
129
128
  _hasAvatarUrl(cachedUrl) ||
130
129
  temporaryAvatarBytes != null;
131
- final result = await _showAvatarBottomSheet(context, hasAvatar: hasAvatar);
132
- if (result == null || !mounted) return;
133
- switch (result) {
134
- case _AvatarAction.camera:
135
- final file = await ImagePicker().pickImage(source: ImageSource.camera);
136
- await _uploadTask(file, userState);
137
- case _AvatarAction.library:
138
- final file = await ImagePicker().pickImage(source: ImageSource.gallery);
139
- await _uploadTask(file, userState);
140
- case _AvatarAction.remove:
141
- await _removeAvatar(userState);
142
- }
143
- }
144
-
145
- Future<_AvatarAction?> _showAvatarBottomSheet(
146
- BuildContext context, {
147
- required bool hasAvatar,
148
- }) {
149
- return showKasyBottomSheet<_AvatarAction>(
130
+ final avatar = t.settings.avatar;
131
+ return showKasyMenu<void>(
150
132
  context: context,
151
- builder: (sheetContext) => KasySheetSurface(
152
- showDragHandle: false,
153
- child: Padding(
154
- padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
155
- child: Column(
156
- mainAxisSize: MainAxisSize.min,
157
- children: [
158
- _BottomSheetTile(
159
- icon: KasyIcons.cameraAlt,
160
- label: t.settings.avatar.take_photo,
161
- onTap: () => Navigator.pop(sheetContext, _AvatarAction.camera),
162
- ),
163
- _BottomSheetTile(
164
- icon: KasyIcons.gallery,
165
- label: t.settings.avatar.choose_library,
166
- onTap: () => Navigator.pop(sheetContext, _AvatarAction.library),
133
+ anchorContext: context,
134
+ desktopWidth: 248,
135
+ sections: [
136
+ KasyMenuSection(
137
+ items: [
138
+ KasyMenuItem(
139
+ icon: KasyIcons.cameraAlt,
140
+ title: avatar.take_photo,
141
+ onTap: () => _pickAndUpload(ImageSource.camera, userState),
142
+ ),
143
+ KasyMenuItem(
144
+ icon: KasyIcons.gallery,
145
+ title: avatar.choose_library,
146
+ onTap: () => _pickAndUpload(ImageSource.gallery, userState),
147
+ ),
148
+ ],
149
+ ),
150
+ // Destructive action lives in its own ("Danger zone") group, per the
151
+ // HeroUI menu guidance.
152
+ if (hasAvatar)
153
+ KasyMenuSection(
154
+ items: [
155
+ KasyMenuItem(
156
+ icon: KasyIcons.trash,
157
+ title: avatar.remove_photo,
158
+ tone: KasyMenuItemTone.danger,
159
+ onTap: () => _removeAvatar(userState),
167
160
  ),
168
- if (hasAvatar) ...[
169
- Divider(
170
- color: sheetContext.colors.onSurface.withValues(alpha: .08),
171
- height: 1,
172
- ),
173
- _BottomSheetTile(
174
- icon: KasyIcons.trash,
175
- label: t.settings.avatar.remove_photo,
176
- color: sheetContext.colors.error,
177
- onTap: () => Navigator.pop(sheetContext, _AvatarAction.remove),
178
- ),
179
- ],
180
161
  ],
181
162
  ),
182
- ),
183
- ),
163
+ ],
184
164
  );
185
165
  }
186
166
 
167
+ Future<void> _pickAndUpload(ImageSource source, UserState userState) async {
168
+ final file = await ImagePicker().pickImage(source: source);
169
+ if (!mounted) return;
170
+ await _uploadTask(file, userState);
171
+ }
172
+
187
173
  Future<void> _uploadTask(XFile? file, UserState userState) async {
188
174
  if (file == null) return;
189
175
  final userId = userState.user.idOrNull;
@@ -301,48 +287,6 @@ class _EditableUserAvatarState extends ConsumerState<EditableUserAvatar> {
301
287
  }
302
288
  }
303
289
 
304
- enum _AvatarAction { camera, library, remove }
305
-
306
- class _BottomSheetTile extends StatelessWidget {
307
- final IconData? icon;
308
- final String label;
309
- final Color? color;
310
- final VoidCallback onTap;
311
-
312
- const _BottomSheetTile({
313
- this.icon,
314
- required this.label,
315
- this.color,
316
- required this.onTap,
317
- });
318
-
319
- @override
320
- Widget build(BuildContext context) {
321
- final Color fg = color ?? context.colors.onSurface;
322
- return KasyHover(
323
- onTap: onTap,
324
- focusable: true,
325
- focusGapColor: context.colors.surface,
326
- borderRadius: BorderRadius.circular(KasyRadius.sm),
327
- padding: const EdgeInsets.symmetric(
328
- horizontal: KasySpacing.md,
329
- vertical: KasySpacing.smd,
330
- ),
331
- child: Row(
332
- children: [
333
- if (icon != null) ...[
334
- Icon(icon, size: KasyIconSize.lg, color: fg),
335
- const SizedBox(width: KasySpacing.sm),
336
- ],
337
- Text(
338
- label,
339
- style: context.textTheme.bodyLarge?.copyWith(color: fg),
340
- ),
341
- ],
342
- ),
343
- );
344
- }
345
- }
346
290
 
347
291
  /// Resolves the avatar [ImageProvider] and renders a [KasyAvatar] with
348
292
  /// [KasyAvatarFallbackSurface.soft]. Firebase Storage URL is loaded async.
@@ -2,7 +2,8 @@ import 'dart:async';
2
2
 
3
3
  import 'package:flutter/material.dart';
4
4
  import 'package:flutter_riverpod/flutter_riverpod.dart';
5
- import 'package:kasy_kit/components/kasy_bottom_sheet.dart';
5
+ import 'package:kasy_kit/components/kasy_menu.dart';
6
+ import 'package:kasy_kit/components/kasy_popover.dart';
6
7
  import 'package:kasy_kit/core/home_widgets/home_widget_mywidget_service.dart';
7
8
  import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
8
9
  import 'package:kasy_kit/core/theme/theme.dart';
@@ -63,118 +64,42 @@ class LanguageSwitcher extends ConsumerWidget {
63
64
  WidgetRef ref,
64
65
  AppLocale current,
65
66
  ) {
66
- final String sheetTitle = context.t.settings.language_title;
67
- showKasyBottomSheet<void>(
67
+ showKasyMenu<void>(
68
68
  context: context,
69
- builder: (sheetContext) => KasySheetSurface(
70
- child: Padding(
71
- // No horizontal padding: option rows go full-bleed so the highlight
72
- // spans the whole sheet (no inset pill). Title and rows carry their
73
- // own horizontal inset instead.
74
- padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
75
- child: Column(
76
- mainAxisSize: MainAxisSize.min,
77
- children: <Widget>[
78
- Padding(
79
- padding: const EdgeInsets.symmetric(horizontal: KasySpacing.md),
80
- child: Align(
81
- alignment: Alignment.centerLeft,
82
- child: Text(
83
- sheetTitle,
84
- style: sheetContext.textTheme.titleMedium?.copyWith(
85
- color: sheetContext.colors.onSurface,
86
- ),
87
- ),
69
+ anchorContext: context,
70
+ // Desktop: hang the menu off the right edge of the settings row, where the
71
+ // current value sits a native desktop menu, not a centered modal.
72
+ desktopAlign: KasyPopoverAlign.end,
73
+ sections: [
74
+ KasyMenuSection(
75
+ label: context.t.settings.language_title,
76
+ selectionMode: KasyMenuSelectionMode.single,
77
+ items: AppLocale.values
78
+ .map(
79
+ (AppLocale locale) => KasyMenuItem(
80
+ title: locale.nativeName,
81
+ selected: locale == current,
82
+ onTap: () => _applyLocale(ref, locale),
88
83
  ),
89
- ),
90
- const SizedBox(height: KasySpacing.xs),
91
- ...AppLocale.values.map((AppLocale locale) {
92
- final bool isSelected = locale == current;
93
- return _LocaleOptionTile(
94
- locale: locale,
95
- isSelected: isSelected,
96
- onTap: () async {
97
- // Close the sheet FIRST. Awaiting work before pop
98
- // races with the rebuild triggered by setLocale and
99
- // crashed Navigator.pop with !_debugLocked.
100
- Navigator.pop(sheetContext);
101
- // Await setLocale so the locale bundle finishes loading
102
- // BEFORE the widget tries to read its translations.
103
- // Slang lazy-loads non-base locales and its loadLocale
104
- // returns immediately when another caller (this setLocale)
105
- // is already loading — so a parallel updateForLocale would
106
- // see translations not yet in the map and silently fall
107
- // back to the base locale (English). That was the root
108
- // cause of "first language tap doesn't update the widget".
109
- await LocaleSettings.setLocale(locale);
110
- unawaited(
111
- ref
112
- .read(sharedPreferencesProvider)
113
- .setAppLocale(locale.languageCode),
114
- );
115
- unawaited(
116
- ref
117
- .read(myWidgetHomeWidgetProvider.notifier)
118
- .updateForLocale(locale),
119
- );
120
- },
121
- );
122
- }),
123
- ],
124
- ),
84
+ )
85
+ .toList(),
125
86
  ),
126
- ),
87
+ ],
127
88
  );
128
89
  }
129
- }
130
90
 
131
- class _LocaleOptionTile extends StatelessWidget {
132
- const _LocaleOptionTile({
133
- required this.locale,
134
- required this.isSelected,
135
- required this.onTap,
136
- });
137
-
138
- final AppLocale locale;
139
- final bool isSelected;
140
- final VoidCallback onTap;
141
-
142
- @override
143
- Widget build(BuildContext context) {
144
- final Color primary = context.colors.primary;
145
- final Color fg = isSelected ? primary : context.colors.onSurface;
146
-
147
- return KasyHover(
148
- onTap: onTap,
149
- focusable: true,
150
- // Full-bleed rectangular highlight (default radius): the sheet rounds its
151
- // own corners, so options span edge-to-edge like a native menu/list.
152
- padding: const EdgeInsets.symmetric(
153
- horizontal: KasySpacing.md,
154
- vertical: KasySpacing.smd,
155
- ),
156
- child: Row(
157
- children: <Widget>[
158
- Expanded(
159
- child: Text(
160
- locale.nativeName,
161
- style: context.textTheme.bodyLarge?.copyWith(
162
- color: fg,
163
- fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
164
- ),
165
- ),
166
- ),
167
- if (isSelected)
168
- Container(
169
- width: 10,
170
- height: 10,
171
- decoration: BoxDecoration(
172
- color: primary,
173
- shape: BoxShape.circle,
174
- ),
175
- ),
176
- ],
177
- ),
91
+ // The menu closes itself before this runs (KasyMenuItem pops first), so the
92
+ // awaited setLocale never races the rebuild it triggers. setLocale is awaited
93
+ // so Slang finishes loading the locale bundle before any widget reads its
94
+ // translations — otherwise a parallel updateForLocale sees an unloaded map and
95
+ // silently falls back to English (the old "first tap doesn't update" bug).
96
+ Future<void> _applyLocale(WidgetRef ref, AppLocale locale) async {
97
+ await LocaleSettings.setLocale(locale);
98
+ unawaited(
99
+ ref.read(sharedPreferencesProvider).setAppLocale(locale.languageCode),
100
+ );
101
+ unawaited(
102
+ ref.read(myWidgetHomeWidgetProvider.notifier).updateForLocale(locale),
178
103
  );
179
104
  }
180
105
  }