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
@@ -179,6 +179,14 @@ class _DeviceSizeBuilderState extends State<DeviceSizeBuilder>
179
179
  /// The maximum width for large devices.
180
180
  const kMaxLargeLayoutWidth = 650.0;
181
181
 
182
+ /// Canonical max width for a single-column internal screen's content (notifications,
183
+ /// settings detail, reminder, ...). On desktop the content is centered within this
184
+ /// width instead of stretching edge-to-edge, so lists/cards never read too wide.
185
+ ///
186
+ /// This is the one knob for "contained internal content" — prefer it over bespoke
187
+ /// per-screen `maxWidth` literals so every internal page lines up to the same ruler.
188
+ const double kKasyContentMaxWidth = 600.0;
189
+
182
190
  /// A widget that constrains its child to a maximum width on large devices.
183
191
  /// It uses the [LayoutBuilder] widget to get the current device width
184
192
  /// and constrains the child to the maximum width on large devices.
@@ -262,16 +262,20 @@ class _GuestContinueButtonState extends ConsumerState<_GuestContinueButton> {
262
262
  bool _loading = false;
263
263
 
264
264
  Future<void> _continue() async {
265
+ // Capture the app-level router BEFORE the await. Creating the guest account
266
+ // flips this button's visibility condition (it only shows while there's no
267
+ // identity), so the widget is disposed the moment the account exists — after
268
+ // that `context`/`ref`/`mounted` are gone. The router survives, so navigate
269
+ // through the captured instance.
270
+ //
271
+ // We navigate explicitly because the redirect intentionally only bounces
272
+ // *authenticated* users off the auth routes (so a guest can still open
273
+ // sign-in to upgrade); it won't carry a freshly-created anonymous guest home.
274
+ final router = GoRouter.of(context);
265
275
  setState(() => _loading = true);
266
276
  try {
267
277
  await ref.read(userStateNotifierProvider.notifier).continueAsGuest();
268
- if (!mounted) return;
269
- // Navigate explicitly: the user is now an anonymous guest (has an id but
270
- // is NOT an AuthenticatedUserData), and the router redirect intentionally
271
- // only bounces *authenticated* users off the auth routes — so a guest can
272
- // still open sign-in to upgrade. That means it won't carry us home on its
273
- // own here; we do it ourselves.
274
- context.go('/');
278
+ router.go('/');
275
279
  } catch (_) {
276
280
  if (!mounted) return;
277
281
  setState(() => _loading = false);
@@ -28,8 +28,22 @@ class HomeComponentsPage extends StatelessWidget {
28
28
  title: 'Components',
29
29
  onBack: () => Navigator.maybePop(context),
30
30
  ),
31
- Expanded(
32
- child: Padding(
31
+ const Expanded(child: HomeComponentsCatalog()),
32
+ ],
33
+ ),
34
+ );
35
+ }
36
+ }
37
+
38
+ /// The UI-kit catalog list on its own (no app bar / scaffold), so it can be the
39
+ /// body of [HomeComponentsPage] AND a section inside the admin console (which
40
+ /// supplies its own chrome). Fills the space it is given.
41
+ class HomeComponentsCatalog extends StatelessWidget {
42
+ const HomeComponentsCatalog({super.key});
43
+
44
+ @override
45
+ Widget build(BuildContext context) {
46
+ return Padding(
33
47
  padding: EdgeInsets.fromLTRB(
34
48
  KasySpacing.pageHorizontalGutter,
35
49
  KasySpacing.belowChromeContentGap,
@@ -158,10 +172,6 @@ class HomeComponentsPage extends StatelessWidget {
158
172
  ),
159
173
  ),
160
174
  ),
161
- ),
162
- ),
163
- ],
164
- ),
165
175
  );
166
176
  }
167
177
  }
@@ -1,6 +1,7 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
3
  import 'package:kasy_kit/components/kasy_app_bar.dart';
4
+ import 'package:kasy_kit/components/kasy_bottom_sheet.dart';
4
5
  import 'package:kasy_kit/components/kasy_card.dart';
5
6
  import 'package:kasy_kit/components/kasy_chip.dart';
6
7
  import 'package:kasy_kit/components/kasy_date_picker.dart';
@@ -126,6 +127,12 @@ class _ReminderForm extends ConsumerWidget {
126
127
  _FieldLabel(tr.dateLabel),
127
128
  const SizedBox(height: KasySpacing.sm),
128
129
  KasyDatePicker(
130
+ // Anchored popover on phones/tablets (the original feel),
131
+ // centered dialog on desktop. The call site picks by width
132
+ // via kasySheetUsesDialog so each presentation stays pure.
133
+ presentation: kasySheetUsesDialog(context)
134
+ ? KasyDatePickerPresentation.dialog
135
+ : KasyDatePickerPresentation.popover,
129
136
  value: state.date,
130
137
  minDate: DateTime.now(),
131
138
  onChanged: (picked) {
@@ -1,5 +1,6 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+ import 'package:kasy_kit/components/kasy_bottom_sheet.dart';
3
4
  import 'package:kasy_kit/core/theme/theme.dart';
4
5
  import 'package:kasy_kit/features/notifications/providers/models/notification.dart';
5
6
  import 'package:kasy_kit/features/notifications/providers/notifications_provider.dart';
@@ -8,10 +9,8 @@ import 'package:kasy_kit/features/settings/ui/widgets/settings_tile.dart';
8
9
  import 'package:kasy_kit/i18n/translations.g.dart';
9
10
 
10
11
  void showNotificationSettingsSheet(BuildContext context) {
11
- showModalBottomSheet<void>(
12
+ showKasyBottomSheet<void>(
12
13
  context: context,
13
- useRootNavigator: true,
14
- backgroundColor: Colors.transparent,
15
14
  isScrollControlled: true,
16
15
  builder: (_) => const _NotificationSettingsSheet(),
17
16
  );
@@ -73,14 +72,18 @@ class _NotificationSettingsSheetState
73
72
  final hasUnread =
74
73
  notificationsState.value?.data.any((n) => !n.seen) ?? false;
75
74
 
76
- return Container(
77
- margin: EdgeInsets.only(
78
- left: KasySpacing.sm,
79
- right: KasySpacing.sm,
80
- bottom: MediaQuery.of(context).viewInsets.bottom +
81
- MediaQuery.of(context).padding.bottom +
82
- KasySpacing.sm,
83
- ),
75
+ final bool floating = kasySheetIsFloating(context);
76
+
77
+ final Widget card = Container(
78
+ margin: floating
79
+ ? EdgeInsets.zero
80
+ : EdgeInsets.only(
81
+ left: KasySpacing.sm,
82
+ right: KasySpacing.sm,
83
+ bottom: MediaQuery.of(context).viewInsets.bottom +
84
+ MediaQuery.of(context).padding.bottom +
85
+ KasySpacing.sm,
86
+ ),
84
87
  decoration: BoxDecoration(
85
88
  color: context.colors.background,
86
89
  borderRadius: KasyRadius.lgBorderRadius,
@@ -91,8 +94,9 @@ class _NotificationSettingsSheetState
91
94
  child: Column(
92
95
  mainAxisSize: MainAxisSize.min,
93
96
  children: [
94
- // drag handle
95
- Padding(
97
+ // drag handle (bottom-sheet form only)
98
+ if (!floating)
99
+ Padding(
96
100
  padding: const EdgeInsets.only(top: KasySpacing.smd),
97
101
  child: Container(
98
102
  width: 36,
@@ -165,5 +169,11 @@ class _NotificationSettingsSheetState
165
169
  ],
166
170
  ),
167
171
  );
172
+
173
+ if (!floating) return card;
174
+ return ConstrainedBox(
175
+ constraints: const BoxConstraints(maxWidth: 420),
176
+ child: card,
177
+ );
168
178
  }
169
179
  }
@@ -5,6 +5,7 @@ import 'package:flutter/rendering.dart';
5
5
  import 'package:flutter_riverpod/flutter_riverpod.dart';
6
6
  import 'package:kasy_kit/components/components.dart';
7
7
  import 'package:kasy_kit/core/theme/theme.dart';
8
+ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
8
9
  import 'package:kasy_kit/features/notifications/providers/models/notification.dart'
9
10
  as app;
10
11
  import 'package:kasy_kit/features/notifications/providers/models/notification_list.dart';
@@ -105,6 +106,8 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
105
106
  return KasyOverlayScaffold(
106
107
  title: t.notifications.title,
107
108
  appBarStyle: KasyAppBarStyle.rootTab,
109
+ // Contain + center the list on desktop so cards never stretch edge-to-edge.
110
+ maxContentWidth: kKasyContentMaxWidth,
108
111
  hideAppBarOnScroll: true,
109
112
  scrollController: _scrollController,
110
113
  trailing: Builder(
@@ -0,0 +1,262 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+ import 'package:go_router/go_router.dart';
4
+ import 'package:kasy_kit/components/components.dart';
5
+ import 'package:kasy_kit/core/theme/theme.dart';
6
+ import 'package:kasy_kit/core/widgets/kasy_hover.dart';
7
+ import 'package:kasy_kit/features/notifications/providers/models/notification.dart'
8
+ as app;
9
+ import 'package:kasy_kit/features/notifications/providers/notifications_provider.dart';
10
+ import 'package:kasy_kit/features/notifications/providers/unread_notifications_count_provider.dart';
11
+ import 'package:kasy_kit/features/notifications/ui/widgets/notification_tile.dart';
12
+ import 'package:kasy_kit/i18n/translations.g.dart';
13
+
14
+ /// Desktop web header bell that opens a recent-notifications dropdown anchored
15
+ /// below it (Gmail / GitHub pattern). The full list still lives at
16
+ /// `/notifications` — the panel's "See all" link goes there.
17
+ ///
18
+ /// Owns its own overlay (OverlayPortal + LayerLink, the same popover technique
19
+ /// as [KasySidebar]), so [KasyWebHeader] stays a pure presentational component
20
+ /// and the data wiring lives here.
21
+ class WebNotificationsBell extends ConsumerStatefulWidget {
22
+ const WebNotificationsBell({super.key});
23
+
24
+ @override
25
+ ConsumerState<WebNotificationsBell> createState() =>
26
+ _WebNotificationsBellState();
27
+ }
28
+
29
+ class _WebNotificationsBellState extends ConsumerState<WebNotificationsBell> {
30
+ /// Max number of recent notifications shown in the panel before "See all".
31
+ static const int _maxPreview = 5;
32
+
33
+ /// Width of the dropdown panel.
34
+ static const double _panelWidth = 380;
35
+
36
+ final OverlayPortalController _controller = OverlayPortalController();
37
+ final LayerLink _link = LayerLink();
38
+
39
+ void _toggle() {
40
+ if (_controller.isShowing) {
41
+ _controller.hide();
42
+ } else {
43
+ _controller.show();
44
+ }
45
+ }
46
+
47
+ void _openFull() {
48
+ _controller.hide();
49
+ context.go('/notifications');
50
+ }
51
+
52
+ void _onTap(app.Notification notification) {
53
+ _controller.hide();
54
+ ref.read(notificationsProvider.notifier).onTapNotification(notification);
55
+ }
56
+
57
+ @override
58
+ Widget build(BuildContext context) {
59
+ final int unread = ref.watch(unreadNotificationsCountProvider).value ?? 0;
60
+ return OverlayPortal(
61
+ controller: _controller,
62
+ overlayChildBuilder: _buildPanel,
63
+ child: CompositedTransformTarget(
64
+ link: _link,
65
+ child: _bell(context, unread: unread),
66
+ ),
67
+ );
68
+ }
69
+
70
+ Widget _bell(BuildContext context, {required int unread}) {
71
+ final Widget button = KasyButton.iconOnly(
72
+ icon: KasyIcons.notification,
73
+ variant: KasyButtonVariant.ghost,
74
+ size: KasyButtonSize.small,
75
+ iconOnlyLayoutExtent: 36,
76
+ iconGlyphSize: KasyIconSize.md,
77
+ onPressed: _toggle,
78
+ semanticLabel: 'Notifications',
79
+ );
80
+ if (unread == 0) return button;
81
+ return Stack(
82
+ clipBehavior: Clip.none,
83
+ children: [
84
+ button,
85
+ Positioned(
86
+ top: 8,
87
+ right: 8,
88
+ child: Container(
89
+ width: 8,
90
+ height: 8,
91
+ decoration: BoxDecoration(
92
+ color: context.colors.error,
93
+ shape: BoxShape.circle,
94
+ border: Border.all(color: context.colors.background, width: 1.5),
95
+ ),
96
+ ),
97
+ ),
98
+ ],
99
+ );
100
+ }
101
+
102
+ Widget _buildPanel(BuildContext context) {
103
+ // Full-screen barrier so a click anywhere outside the panel dismisses it.
104
+ return Stack(
105
+ children: [
106
+ Positioned.fill(
107
+ child: GestureDetector(
108
+ behavior: HitTestBehavior.opaque,
109
+ onTap: _controller.hide,
110
+ ),
111
+ ),
112
+ CompositedTransformFollower(
113
+ link: _link,
114
+ showWhenUnlinked: false,
115
+ targetAnchor: Alignment.bottomRight,
116
+ followerAnchor: Alignment.topRight,
117
+ // Small gap below the bell — the icon button already has internal
118
+ // padding, so 6 reads "attached to the bell" without looking glued.
119
+ offset: const Offset(0, 6),
120
+ child: Align(
121
+ alignment: Alignment.topRight,
122
+ child: _panelCard(context),
123
+ ),
124
+ ),
125
+ ],
126
+ );
127
+ }
128
+
129
+ Widget _panelCard(BuildContext context) {
130
+ final KasyColors c = context.colors;
131
+ return Material(
132
+ color: Colors.transparent,
133
+ child: Container(
134
+ width: _panelWidth,
135
+ constraints: const BoxConstraints(maxHeight: 480),
136
+ decoration: BoxDecoration(
137
+ color: c.surface,
138
+ // Same hairline as the sidebar/header chrome: `c.border` at 0.5px, so
139
+ // the whole web chrome reads as one continuous border weight.
140
+ border: Border.all(color: c.border, width: 0.5),
141
+ borderRadius: KasyRadius.lgBorderRadius,
142
+ // Floating dropdown → the design-system overlay shadow (menus/popovers),
143
+ // not the inline-card `component` shadow (which is muted on web).
144
+ boxShadow: KasyShadows.overlay,
145
+ ),
146
+ clipBehavior: Clip.antiAlias,
147
+ child: Column(
148
+ mainAxisSize: MainAxisSize.min,
149
+ children: [
150
+ _header(context),
151
+ Flexible(child: _body(context)),
152
+ Divider(height: 1, thickness: 1, color: c.border),
153
+ _footer(context),
154
+ ],
155
+ ),
156
+ ),
157
+ );
158
+ }
159
+
160
+ Widget _header(BuildContext context) {
161
+ final int unread = ref.watch(unreadNotificationsCountProvider).value ?? 0;
162
+ return Padding(
163
+ padding: const EdgeInsets.fromLTRB(
164
+ KasySpacing.md,
165
+ KasySpacing.smd,
166
+ KasySpacing.sm,
167
+ KasySpacing.smd,
168
+ ),
169
+ child: Row(
170
+ children: [
171
+ Expanded(
172
+ child: Text(
173
+ t.notifications.title,
174
+ style: context.textTheme.titleSmall?.copyWith(
175
+ fontWeight: FontWeight.w600,
176
+ ),
177
+ ),
178
+ ),
179
+ if (unread > 0)
180
+ KasyButton(
181
+ label: t.notifications.mark_all_read,
182
+ variant: KasyButtonVariant.ghost,
183
+ size: KasyButtonSize.small,
184
+ onPressed: () =>
185
+ ref.read(notificationsProvider.notifier).readAll(),
186
+ ),
187
+ ],
188
+ ),
189
+ );
190
+ }
191
+
192
+ Widget _body(BuildContext context) {
193
+ final state = ref.watch(notificationsProvider);
194
+ return state.when(
195
+ loading: () => Column(
196
+ mainAxisSize: MainAxisSize.min,
197
+ children: List<Widget>.generate(
198
+ 3,
199
+ (_) => const NotificationSkeletonTile(),
200
+ ),
201
+ ),
202
+ error: (_, _) => _message(context, t.notifications.error_fetching),
203
+ data: (list) {
204
+ if (list.data.isEmpty) {
205
+ return _message(context, t.notifications.empty_title);
206
+ }
207
+ final items = list.data.take(_maxPreview).toList();
208
+ return SingleChildScrollView(
209
+ child: Column(
210
+ mainAxisSize: MainAxisSize.min,
211
+ children: [
212
+ for (int i = 0; i < items.length; i++) ...[
213
+ if (i > 0)
214
+ Divider(height: 1, thickness: 1, color: context.colors.border),
215
+ KasyHover(
216
+ onTap: () => _onTap(items[i]),
217
+ padding: const EdgeInsets.symmetric(
218
+ horizontal: KasySpacing.md,
219
+ vertical: KasySpacing.smd,
220
+ ),
221
+ child: NotificationTile.from(context, items[i]),
222
+ ),
223
+ ],
224
+ ],
225
+ ),
226
+ );
227
+ },
228
+ );
229
+ }
230
+
231
+ Widget _message(BuildContext context, String text) {
232
+ return Padding(
233
+ padding: const EdgeInsets.symmetric(
234
+ horizontal: KasySpacing.md,
235
+ vertical: KasySpacing.xl,
236
+ ),
237
+ child: Center(
238
+ child: Text(
239
+ text,
240
+ style: context.textTheme.bodyMedium?.copyWith(
241
+ color: context.colors.muted,
242
+ ),
243
+ ),
244
+ ),
245
+ );
246
+ }
247
+
248
+ Widget _footer(BuildContext context) {
249
+ return Padding(
250
+ padding: const EdgeInsets.all(KasySpacing.sm),
251
+ child: SizedBox(
252
+ width: double.infinity,
253
+ child: KasyButton(
254
+ label: t.notifications.see_all,
255
+ variant: KasyButtonVariant.neutral,
256
+ size: KasyButtonSize.small,
257
+ onPressed: _openFull,
258
+ ),
259
+ ),
260
+ );
261
+ }
262
+ }
@@ -15,6 +15,7 @@ import 'package:kasy_kit/core/states/logout_action.dart';
15
15
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
16
16
  import 'package:kasy_kit/core/theme/theme.dart';
17
17
  import 'package:kasy_kit/core/widgets/kasy_hover.dart';
18
+ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
18
19
  import 'package:kasy_kit/features/authentication/repositories/authentication_repository.dart';
19
20
  import 'package:kasy_kit/features/authentication/repositories/exceptions/authentication_exceptions.dart';
20
21
  import 'package:kasy_kit/features/settings/ui/components/avatar_component.dart';
@@ -580,6 +581,8 @@ class _SettingsDesktopView extends ConsumerStatefulWidget {
580
581
  class _SettingsDesktopViewState extends ConsumerState<_SettingsDesktopView> {
581
582
  _DesktopSection _selected = _DesktopSection.account;
582
583
 
584
+ void _select(_DesktopSection s) => setState(() => _selected = s);
585
+
583
586
  List<_DesktopSection> get _sections => <_DesktopSection>[
584
587
  _DesktopSection.account,
585
588
  _DesktopSection.preferences,
@@ -593,47 +596,57 @@ class _SettingsDesktopViewState extends ConsumerState<_SettingsDesktopView> {
593
596
  final List<_DesktopSection> sections = _sections;
594
597
  if (!sections.contains(_selected)) _selected = sections.first;
595
598
 
599
+ final Widget pane = _DesktopDetail(
600
+ section: _selected,
601
+ userId: widget.userId,
602
+ name: widget.name,
603
+ editableName: widget.editableName,
604
+ email: widget.email,
605
+ isAuthenticated: widget.isAuthenticated,
606
+ isPhone: widget.isPhone,
607
+ );
608
+
609
+ const double navWidth = 248;
610
+ const double gap = KasySpacing.xxl;
611
+ const double groupWidth = navWidth + gap + kKasyContentMaxWidth;
612
+
596
613
  return Center(
597
- child: ConstrainedBox(
598
- constraints: const BoxConstraints(maxWidth: 1080),
599
- child: Padding(
600
- padding: const EdgeInsets.only(
601
- top: KasySpacing.lg,
602
- bottom: KasySpacing.xxl,
603
- ),
604
- child: Row(
605
- crossAxisAlignment: CrossAxisAlignment.start,
606
- children: [
607
- SizedBox(
608
- width: 248,
609
- child: _DesktopNav(
610
- sections: sections,
611
- selected: _selected,
612
- name: widget.name,
613
- email: widget.email,
614
- onSelect: (s) => setState(() => _selected = s),
615
- ),
616
- ),
617
- const SizedBox(width: KasySpacing.xxl),
618
- Expanded(
619
- child: Align(
620
- alignment: Alignment.topLeft,
621
- child: ConstrainedBox(
622
- constraints: const BoxConstraints(maxWidth: 600),
623
- child: _DesktopDetail(
624
- section: _selected,
625
- userId: widget.userId,
626
- name: widget.name,
627
- editableName: widget.editableName,
628
- email: widget.email,
629
- isAuthenticated: widget.isAuthenticated,
630
- isPhone: widget.isPhone,
631
- ),
614
+ child: Padding(
615
+ padding: const EdgeInsets.only(
616
+ top: KasySpacing.lg,
617
+ bottom: KasySpacing.xxl,
618
+ ),
619
+ // Center the nav + detail as ONE unit so the whitespace is equal on
620
+ // both sides (Linear/Notion settings), instead of pinning the nav left
621
+ // and leaving the right empty. Settings are forms, so the detail keeps
622
+ // a comfortable reading width rather than stretching wide. On tight
623
+ // desktop widths the detail fills the remaining space so it never
624
+ // overflows.
625
+ child: LayoutBuilder(
626
+ builder: (context, constraints) {
627
+ final bool fits = constraints.maxWidth >= groupWidth;
628
+ return Row(
629
+ mainAxisSize: fits ? MainAxisSize.min : MainAxisSize.max,
630
+ crossAxisAlignment: CrossAxisAlignment.start,
631
+ children: [
632
+ SizedBox(
633
+ width: navWidth,
634
+ child: _DesktopNav(
635
+ sections: sections,
636
+ selected: _selected,
637
+ name: widget.name,
638
+ email: widget.email,
639
+ onSelect: _select,
632
640
  ),
633
641
  ),
634
- ),
635
- ],
636
- ),
642
+ const SizedBox(width: gap),
643
+ if (fits)
644
+ SizedBox(width: kKasyContentMaxWidth, child: pane)
645
+ else
646
+ Expanded(child: pane),
647
+ ],
648
+ );
649
+ },
637
650
  ),
638
651
  ),
639
652
  );