kasy-cli 1.37.1 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/lib/scaffold/CHANGELOG.json +23 -0
  2. package/lib/scaffold/backends/api/patch/README.md +15 -0
  3. package/lib/scaffold/backends/api/patch/lib/core/data/api/user_api.dart +18 -0
  4. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +11 -6
  5. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  6. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  7. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  8. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  10. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  11. package/lib/scaffold/backends/supabase/patch/lib/core/data/api/user_api.dart +8 -0
  12. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  13. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  15. package/lib/scaffold/shared/generator-utils.js +12 -6
  16. package/package.json +1 -1
  17. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  18. package/templates/firebase/AGENTS.md +7 -1
  19. package/templates/firebase/DESIGN_SYSTEM.md +35 -8
  20. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  21. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  22. package/templates/firebase/assets/icons/facebook.svg +49 -0
  23. package/templates/firebase/assets/icons/google.svg +1 -0
  24. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  25. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  26. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  27. package/templates/firebase/lib/components/components.dart +1 -1
  28. package/templates/firebase/lib/components/kasy_app_bar.dart +361 -20
  29. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +283 -66
  30. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +61 -46
  32. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +412 -31
  34. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  35. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  36. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +29 -231
  37. package/templates/firebase/lib/core/bottom_menu/sidebar_focus.dart +224 -0
  38. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +19 -9
  39. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  40. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  41. package/templates/firebase/lib/core/data/api/user_api.dart +15 -0
  42. package/templates/firebase/lib/core/data/repositories/user_repository.dart +5 -0
  43. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +525 -65
  44. package/templates/firebase/lib/core/dev_inspector/dev_inspector_info.dart +47 -0
  45. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  46. package/templates/firebase/lib/core/icons/kasy_icons.dart +16 -1
  47. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +18 -35
  48. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  49. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  50. package/templates/firebase/lib/core/states/user_state_notifier.dart +69 -1
  51. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  52. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  53. package/templates/firebase/lib/core/theme/universal_theme.dart +9 -0
  54. package/templates/firebase/lib/core/web_device_preview/png_clipboard.dart +14 -0
  55. package/templates/firebase/lib/core/web_device_preview/png_clipboard_io.dart +9 -0
  56. package/templates/firebase/lib/core/web_device_preview/png_clipboard_web.dart +36 -0
  57. package/templates/firebase/lib/core/web_device_preview/png_export_result.dart +2 -0
  58. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +547 -483
  59. package/templates/firebase/lib/core/web_viewport_scale.dart +64 -35
  60. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  61. package/templates/firebase/lib/core/widgets/responsive_layout.dart +8 -0
  62. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  63. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  66. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  67. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  68. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  69. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  70. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  71. package/templates/firebase/lib/features/home/home_components_page.dart +264 -126
  72. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +231 -57
  73. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  74. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  75. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  76. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +118 -57
  77. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  78. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +19 -4
  79. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  80. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  81. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  82. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +262 -0
  83. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  84. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  85. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  86. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  87. package/templates/firebase/lib/features/settings/settings_page.dart +99 -65
  88. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +1379 -422
  89. package/templates/firebase/lib/features/settings/ui/components/admin/admin_routes.dart +14 -4
  90. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  91. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  92. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +404 -149
  93. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +24 -31
  94. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  95. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +77 -95
  96. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  97. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  98. package/templates/firebase/lib/i18n/en.i18n.json +749 -698
  99. package/templates/firebase/lib/i18n/es.i18n.json +749 -698
  100. package/templates/firebase/lib/i18n/pt.i18n.json +749 -698
  101. package/templates/firebase/lib/main.dart +20 -7
  102. package/templates/firebase/lib/router.dart +70 -46
  103. package/templates/firebase/pubspec.yaml +2 -1
  104. package/templates/firebase/test/admin_shell_chrome_test.dart +110 -0
  105. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  106. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  107. package/templates/firebase/test/features/authentication/data/api/user_api_fake.dart +3 -0
  108. package/templates/firebase/tool/design_check.dart +9 -0
  109. package/templates/firebase/assets/icons/apple.png +0 -0
  110. package/templates/firebase/assets/icons/facebook.png +0 -0
  111. package/templates/firebase/assets/icons/google.png +0 -0
  112. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  113. package/templates/firebase/lib/components/kasy_web_header.dart +0 -210
  114. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  115. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  116. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  117. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  118. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -169
  119. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
  120. package/templates/firebase/lib/features/settings/ui/components/admin/admin_paywalls.dart +0 -53
@@ -30,7 +30,25 @@ class OnboardingNotifier extends _$OnboardingNotifier {
30
30
  return OnboardingState();
31
31
  }
32
32
 
33
+ /// Enter/leave preview mode. Called once when [OnboardingPage] mounts: the
34
+ /// admin Debug "Test onboarding" entry sets `true`, the real flow sets
35
+ /// `false`, so the flag is always correct on every entry.
36
+ void setPreview(bool value) {
37
+ if (state.preview == value) return;
38
+ state = state.copyWith(preview: value);
39
+ }
40
+
33
41
  Future<void> onAnsweredQuestion(UserInfoDetail value) async {
42
+ // Preview: never touch the admin's real profile. Buffer the answer so the
43
+ // question screens still behave (selection persists across steps), but it's
44
+ // discarded with the provider state once the preview ends.
45
+ if (state.preview) {
46
+ final others = state.pendingUserInfo
47
+ .where((info) => info.runtimeType != value.runtimeType)
48
+ .toList();
49
+ state = state.copyWith(pendingUserInfo: [...others, value]);
50
+ return;
51
+ }
34
52
  final userId = ref.read(userStateNotifierProvider).user.idOrNull;
35
53
  if (userId != null) {
36
54
  // Account already exists (e.g. a returning guest re-onboarding): save now.
@@ -47,6 +65,10 @@ class OnboardingNotifier extends _$OnboardingNotifier {
47
65
  }
48
66
 
49
67
  Future<void> setupNotifications() async {
68
+ // Preview: don't fire the real OS permission dialog or log analytics — the
69
+ // permission screen is shown for review only.
70
+ if (state.preview) return;
71
+
50
72
  final userStateNotifier = ref.read(userStateNotifierProvider.notifier);
51
73
  final notificationsRepository = ref.read(notificationRepositoryProvider);
52
74
 
@@ -75,6 +97,10 @@ class OnboardingNotifier extends _$OnboardingNotifier {
75
97
  }
76
98
 
77
99
  Future<void> onOnboardingCompleted() async {
100
+ // Preview: this is the step that would create the anonymous guest account
101
+ // and flush profile writes. Do nothing — the preview just returns to Debug.
102
+ if (state.preview) return;
103
+
78
104
  final userStateNotifier = ref.read(userStateNotifierProvider.notifier);
79
105
 
80
106
  // This is the "preparing everything for you" moment (the loader screen):
@@ -106,6 +132,8 @@ class OnboardingNotifier extends _$OnboardingNotifier {
106
132
  /// Skip onboarding: mark as onboarded instantly (optimistic) and navigate
107
133
  /// to home. Permissions (push + ATT) are requested on the home screen.
108
134
  void skipOnboarding() {
135
+ // Preview: skipping must not mark the real user as onboarded.
136
+ if (state.preview) return;
109
137
  ref.read(userStateNotifierProvider.notifier).onSkippedOnboarding();
110
138
  }
111
139
  }
@@ -9,22 +9,55 @@ import 'package:kasy_kit/features/onboarding/ui/components/onboarding_features.d
9
9
  import 'package:kasy_kit/features/onboarding/ui/components/onboarding_loader.dart';
10
10
  import 'package:kasy_kit/features/onboarding/ui/components/onboarding_notifications_setup.dart';
11
11
  import 'package:kasy_kit/features/onboarding/ui/components/onboarding_questions.dart';
12
+ import 'package:kasy_kit/features/settings/ui/components/admin/admin_routes.dart';
12
13
  import 'package:kasy_kit/features/subscriptions/ui/premium_page.dart';
13
14
  import 'package:kasy_kit/router.dart';
14
15
 
15
16
 
16
- class OnboardingPage extends ConsumerWidget {
17
- const OnboardingPage({super.key});
17
+ class OnboardingPage extends ConsumerStatefulWidget {
18
+ /// Preview mode: the flow is opened from the admin Debug screen just to walk
19
+ /// the screens. Real side effects are suppressed (see [OnboardingNotifier])
20
+ /// and the flow returns to Debug instead of Home/paywall.
21
+ final bool preview;
22
+
23
+ const OnboardingPage({super.key, this.preview = false});
24
+
25
+ @override
26
+ ConsumerState<OnboardingPage> createState() => _OnboardingPageState();
27
+ }
28
+
29
+ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
30
+ @override
31
+ void initState() {
32
+ super.initState();
33
+ // Set the flag on every entry (true for the admin preview, false for the
34
+ // real flow) so the notifier's side-effect guards are always correct. The
35
+ // first screen has no side effects, so a post-frame write is safe.
36
+ WidgetsBinding.instance.addPostFrameCallback((_) {
37
+ if (!mounted) return;
38
+ ref.read(onboardingProvider.notifier).setPreview(widget.preview);
39
+ });
40
+ }
41
+
42
+ /// Where the flow lands when finished: back to the admin Debug screen in
43
+ /// preview, Home otherwise.
44
+ void _exit() => ref
45
+ .read(goRouterProvider)
46
+ .go(widget.preview ? adminRouteDebug : '/');
18
47
 
19
48
  @override
20
- Widget build(BuildContext context, WidgetRef ref) {
49
+ Widget build(BuildContext context) {
50
+ final bool preview = widget.preview;
21
51
  return Navigator(
22
52
  initialRoute: 'feature_1',
53
+ // No analytics in preview — walking the flow from Debug must not pollute
54
+ // real onboarding funnels.
23
55
  observers: [
24
- AnalyticsObserver(
25
- prefix: 'userOnboarding/',
26
- analyticsApi: MixpanelAnalyticsApi.instance(),
27
- ),
56
+ if (!preview)
57
+ AnalyticsObserver(
58
+ prefix: 'userOnboarding/',
59
+ analyticsApi: MixpanelAnalyticsApi.instance(),
60
+ ),
28
61
  ],
29
62
  onGenerateRoute: (settings) => switch (settings.name) {
30
63
  'feature_1' => OnboardingRouteTransition(
@@ -33,7 +66,11 @@ class OnboardingPage extends ConsumerWidget {
33
66
  onSkip: () => Navigator.of(context).pushReplacementNamed(
34
67
  'skip_loader',
35
68
  ),
36
- onLogin: () => ref.read(goRouterProvider).go('/signin'),
69
+ // In preview, leaving to the real sign-in screen would break the
70
+ // "everything just returns to Debug" contract, so go back instead.
71
+ onLogin: () => preview
72
+ ? _exit()
73
+ : ref.read(goRouterProvider).go('/signin'),
37
74
  ),
38
75
  settings: settings,
39
76
  ),
@@ -79,9 +116,11 @@ class OnboardingPage extends ConsumerWidget {
79
116
  ),
80
117
  'loader' => OnboardingRouteTransition(
81
118
  builder: (context) => OnboardingLoader(
82
- onCompleted: () => Navigator.of(context).pushReplacementNamed(
83
- 'paywall',
84
- ),
119
+ // Preview stops here (the paywall has its own admin preview) and
120
+ // returns to Debug; the real flow continues to the paywall.
121
+ onCompleted: () => preview
122
+ ? _exit()
123
+ : Navigator.of(context).pushReplacementNamed('paywall'),
85
124
  ),
86
125
  settings: settings,
87
126
  ),
@@ -89,7 +128,7 @@ class OnboardingPage extends ConsumerWidget {
89
128
  builder: (context) => OnboardingLoader(
90
129
  onCompleted: () {
91
130
  ref.onboardingNotifier.skipOnboarding();
92
- ref.read(goRouterProvider).go('/');
131
+ _exit();
93
132
  },
94
133
  ),
95
134
  settings: settings,
@@ -73,22 +73,10 @@ class _OnboardingSelectableRowGroupState
73
73
  return Column(
74
74
  crossAxisAlignment: CrossAxisAlignment.stretch,
75
75
  children: [
76
- KasyFocusRing(
77
- onActivate: selectRow,
76
+ _SelectableTileInk(
77
+ onTap: selectRow,
78
78
  borderRadius: KasyRadius.lgBorderRadius,
79
- child: Material(
80
- color: Colors.transparent,
81
- borderRadius: KasyRadius.lgBorderRadius,
82
- child: InkWell(
83
- canRequestFocus: false,
84
- borderRadius: KasyRadius.lgBorderRadius,
85
- onTap: selectRow,
86
- child: switch (hasOnSelectInfo && _selectedIndex == index) {
87
- false => widget.options[index],
88
- true => widget.options[index],
89
- },
90
- ),
91
- ),
79
+ child: widget.options[index],
92
80
  ),
93
81
  if (hasOnSelectInfo && _selectedIndex == index)
94
82
  Padding(
@@ -150,23 +138,16 @@ class _OnboardingMultiSelectableRowGroupState
150
138
  setState(() {});
151
139
  }
152
140
 
153
- return KasyFocusRing(
154
- onActivate: toggleSelection,
155
- borderRadius: KasyRadius.mdBorderRadius,
156
- child: Material(
157
- color: Colors.transparent,
158
- borderRadius: KasyRadius.mdBorderRadius,
159
- child: InkWell(
160
- canRequestFocus: false,
161
- splashColor: context.colors.primary.withValues(alpha: .1),
162
- onTap: toggleSelection,
163
- child: SelectableRowTile(
164
- title: option.title,
165
- subtitle: option.subtitle,
166
- selected: _selectedIndex.contains(index),
167
- emoj: option.emoj,
168
- ),
169
- ),
141
+ return _SelectableTileInk(
142
+ onTap: toggleSelection,
143
+ // Match the tile's own lgBorderRadius so the hover veil and focus ring
144
+ // hug its shape (the old InkWell clipped at md, a mismatch).
145
+ borderRadius: KasyRadius.lgBorderRadius,
146
+ child: SelectableRowTile(
147
+ title: option.title,
148
+ subtitle: option.subtitle,
149
+ selected: _selectedIndex.contains(index),
150
+ emoj: option.emoj,
170
151
  ),
171
152
  );
172
153
  }).toList(),
@@ -338,6 +319,83 @@ class _SelectableRowTileState extends State<SelectableRowTile>
338
319
  }
339
320
  }
340
321
 
322
+ /// Adds a web hover highlight, a keyboard focus ring and a click cursor on top
323
+ /// of an opaque selectable tile — no Material ripple.
324
+ ///
325
+ /// Because the tile paints its own background, the hover veil is layered ABOVE
326
+ /// the child (clipped to [borderRadius]), the same approach the tappable
327
+ /// [KasyCard] uses; a [KasyHover]-style overlay would sit behind the opaque
328
+ /// surface and never show. The child keeps its own semantics (the option title),
329
+ /// so screen readers still announce the option, not a generic label.
330
+ class _SelectableTileInk extends StatefulWidget {
331
+ const _SelectableTileInk({
332
+ required this.onTap,
333
+ required this.borderRadius,
334
+ required this.child,
335
+ });
336
+
337
+ final VoidCallback onTap;
338
+ final BorderRadius borderRadius;
339
+ final Widget child;
340
+
341
+ @override
342
+ State<_SelectableTileInk> createState() => _SelectableTileInkState();
343
+ }
344
+
345
+ class _SelectableTileInkState extends State<_SelectableTileInk> {
346
+ bool _hovered = false;
347
+ bool _pressed = false;
348
+
349
+ @override
350
+ Widget build(BuildContext context) {
351
+ final bool dark = context.isDark;
352
+ // Subtle tint that fades in on hover (web/desktop) and deepens briefly on
353
+ // press; invisible at rest. On touch _hovered never becomes true.
354
+ final double alpha = _pressed
355
+ ? (dark ? 0.10 : 0.06)
356
+ : (_hovered ? (dark ? 0.06 : 0.04) : 0.0);
357
+
358
+ final Widget veiled = Stack(
359
+ children: <Widget>[
360
+ widget.child,
361
+ Positioned.fill(
362
+ child: IgnorePointer(
363
+ child: AnimatedContainer(
364
+ duration: const Duration(milliseconds: 120),
365
+ curve: Curves.easeOut,
366
+ decoration: BoxDecoration(
367
+ color: context.colors.onSurface.withValues(alpha: alpha),
368
+ borderRadius: widget.borderRadius,
369
+ ),
370
+ ),
371
+ ),
372
+ ),
373
+ ],
374
+ );
375
+
376
+ return KasyFocusRing(
377
+ onActivate: widget.onTap,
378
+ borderRadius: widget.borderRadius,
379
+ child: MouseRegion(
380
+ cursor: SystemMouseCursors.click,
381
+ onEnter: (_) => setState(() => _hovered = true),
382
+ onExit: (_) => setState(() {
383
+ _hovered = false;
384
+ _pressed = false;
385
+ }),
386
+ child: GestureDetector(
387
+ behavior: HitTestBehavior.opaque,
388
+ onTap: widget.onTap,
389
+ onTapDown: (_) => setState(() => _pressed = true),
390
+ onTapUp: (_) => setState(() => _pressed = false),
391
+ onTapCancel: () => setState(() => _pressed = false),
392
+ child: veiled,
393
+ ),
394
+ ),
395
+ );
396
+ }
397
+ }
398
+
341
399
  class RoundRadioBox extends StatelessWidget {
342
400
  final Color bgColor;
343
401
  final Color borderColor;
@@ -152,6 +152,7 @@ class SettingsPage extends ConsumerWidget {
152
152
  context,
153
153
  ref,
154
154
  isAuthenticated: isAuthenticated,
155
+ hasAccount: userId != null,
155
156
  isAdmin: user.isAdmin,
156
157
  isPhone: isPhone,
157
158
  ),
@@ -181,6 +182,7 @@ class SettingsPage extends ConsumerWidget {
181
182
  BuildContext context,
182
183
  WidgetRef ref, {
183
184
  required bool isAuthenticated,
185
+ required bool hasAccount,
184
186
  required bool isAdmin,
185
187
  required bool isPhone,
186
188
  }) {
@@ -214,7 +216,7 @@ class SettingsPage extends ConsumerWidget {
214
216
  padding: const EdgeInsets.only(left: KasySpacing.xs),
215
217
  child: Text(
216
218
  t.admin_console.settings_entry.caption,
217
- style: context.textTheme.bodySmall?.copyWith(
219
+ style: context.textTheme.bodyMedium?.copyWith(
218
220
  color: context.colors.muted,
219
221
  ),
220
222
  ),
@@ -225,8 +227,13 @@ class SettingsPage extends ConsumerWidget {
225
227
  _settingsGroup([_LogoutRow(onTap: () => confirmLogout(context, ref))]),
226
228
  const SizedBox(height: KasySpacing.xl),
227
229
  ],
228
- const DeleteUserButton(),
229
- const SizedBox(height: KasySpacing.xl),
230
+ // Only offer account deletion when there's a real backend account behind
231
+ // it. A guest with no identity has nothing to delete, so the button would
232
+ // just dead-end in an error and trap them on this screen.
233
+ if (hasAccount) ...[
234
+ const DeleteUserButton(),
235
+ const SizedBox(height: KasySpacing.xl),
236
+ ],
230
237
  const _VersionLabel(),
231
238
  ];
232
239
  }
@@ -434,10 +441,9 @@ class SettingsContainer extends StatelessWidget {
434
441
  Widget build(BuildContext context) {
435
442
  return KasyCard(
436
443
  borderRadius: BorderRadius.circular(KasyRadius.lg),
437
- padding: const EdgeInsets.symmetric(
438
- horizontal: KasySpacing.md,
439
- vertical: KasySpacing.xs,
440
- ),
444
+ // No card padding: each row carries its own inset so the press/hover
445
+ // highlight spans the full card width (clipped to the rounded corners),
446
+ // instead of a pill floating inside a white margin.
441
447
  child: child,
442
448
  );
443
449
  }
@@ -459,7 +465,7 @@ class _FieldRow extends StatelessWidget {
459
465
  children: [
460
466
  Text(
461
467
  label,
462
- style: context.textTheme.titleSmall?.copyWith(
468
+ style: context.kasyTextTheme.listRowTitle.copyWith(
463
469
  color: context.colors.onSurface,
464
470
  ),
465
471
  ),
@@ -470,7 +476,7 @@ class _FieldRow extends StatelessWidget {
470
476
  textAlign: TextAlign.right,
471
477
  maxLines: 1,
472
478
  overflow: TextOverflow.ellipsis,
473
- style: context.textTheme.bodyMedium?.copyWith(
479
+ style: context.kasyTextTheme.listRowValue.copyWith(
474
480
  color: context.colors.muted,
475
481
  ),
476
482
  ),
@@ -483,18 +489,22 @@ class _FieldRow extends StatelessWidget {
483
489
  );
484
490
  if (onTap == null) {
485
491
  return Padding(
486
- padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
492
+ padding: const EdgeInsets.symmetric(
493
+ horizontal: KasySpacing.md,
494
+ vertical: KasySpacing.smd,
495
+ ),
487
496
  child: row,
488
497
  );
489
498
  }
490
499
  return KasyHover(
491
500
  onTap: onTap!,
492
- hoverEnabled: false,
493
- pressEnabled: false,
494
501
  focusable: true,
495
- borderRadius: KasyRadius.smBorderRadius,
502
+ // Rectangular highlight (default): the card clips the rounded ends.
496
503
  semanticLabel: label,
497
- padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
504
+ padding: const EdgeInsets.symmetric(
505
+ horizontal: KasySpacing.md,
506
+ vertical: KasySpacing.smd,
507
+ ),
498
508
  child: row,
499
509
  );
500
510
  }
@@ -511,7 +521,7 @@ class _AccountAvatarHeader extends StatelessWidget {
511
521
  return const Center(
512
522
  child: Padding(
513
523
  padding: EdgeInsets.only(top: KasySpacing.sm),
514
- child: EditableUserAvatar(diameter: 64),
524
+ child: EditableUserAvatar(diameter: 80),
515
525
  ),
516
526
  );
517
527
  }
@@ -580,6 +590,8 @@ class _SettingsDesktopView extends ConsumerStatefulWidget {
580
590
  class _SettingsDesktopViewState extends ConsumerState<_SettingsDesktopView> {
581
591
  _DesktopSection _selected = _DesktopSection.account;
582
592
 
593
+ void _select(_DesktopSection s) => setState(() => _selected = s);
594
+
583
595
  List<_DesktopSection> get _sections => <_DesktopSection>[
584
596
  _DesktopSection.account,
585
597
  _DesktopSection.preferences,
@@ -593,47 +605,58 @@ class _SettingsDesktopViewState extends ConsumerState<_SettingsDesktopView> {
593
605
  final List<_DesktopSection> sections = _sections;
594
606
  if (!sections.contains(_selected)) _selected = sections.first;
595
607
 
608
+ final Widget pane = _DesktopDetail(
609
+ section: _selected,
610
+ userId: widget.userId,
611
+ name: widget.name,
612
+ editableName: widget.editableName,
613
+ email: widget.email,
614
+ isAuthenticated: widget.isAuthenticated,
615
+ isPhone: widget.isPhone,
616
+ );
617
+
618
+ const double navWidth = 220;
619
+ const double gap = KasySpacing.xl;
620
+ const double detailWidth = 560;
621
+ const double groupWidth = navWidth + gap + detailWidth;
622
+
596
623
  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
- ),
624
+ child: Padding(
625
+ padding: const EdgeInsets.only(
626
+ top: KasySpacing.lg,
627
+ bottom: KasySpacing.xxl,
628
+ ),
629
+ // Center the nav + detail as ONE unit so the whitespace is equal on
630
+ // both sides (Linear/Notion settings), instead of pinning the nav left
631
+ // and leaving the right empty. Settings are forms, so the detail keeps
632
+ // a comfortable reading width rather than stretching wide. On tight
633
+ // desktop widths the detail fills the remaining space so it never
634
+ // overflows.
635
+ child: LayoutBuilder(
636
+ builder: (context, constraints) {
637
+ final bool fits = constraints.maxWidth >= groupWidth;
638
+ return Row(
639
+ mainAxisSize: fits ? MainAxisSize.min : MainAxisSize.max,
640
+ crossAxisAlignment: CrossAxisAlignment.start,
641
+ children: [
642
+ SizedBox(
643
+ width: navWidth,
644
+ child: _DesktopNav(
645
+ sections: sections,
646
+ selected: _selected,
647
+ name: widget.name,
648
+ email: widget.email,
649
+ onSelect: _select,
632
650
  ),
633
651
  ),
634
- ),
635
- ],
636
- ),
652
+ const SizedBox(width: gap),
653
+ if (fits)
654
+ SizedBox(width: detailWidth, child: pane)
655
+ else
656
+ Expanded(child: pane),
657
+ ],
658
+ );
659
+ },
637
660
  ),
638
661
  ),
639
662
  );
@@ -669,7 +692,7 @@ class _DesktopNav extends StatelessWidget {
669
692
  ),
670
693
  child: Row(
671
694
  children: [
672
- const EditableUserAvatar(diameter: 40),
695
+ const EditableUserAvatar(diameter: 64),
673
696
  const SizedBox(width: KasySpacing.sm),
674
697
  Expanded(
675
698
  child: Column(
@@ -689,7 +712,7 @@ class _DesktopNav extends StatelessWidget {
689
712
  email,
690
713
  maxLines: 1,
691
714
  overflow: TextOverflow.ellipsis,
692
- style: context.textTheme.bodySmall?.copyWith(
715
+ style: context.textTheme.bodyMedium?.copyWith(
693
716
  color: context.colors.muted,
694
717
  ),
695
718
  ),
@@ -742,7 +765,7 @@ class _NavTile extends StatelessWidget {
742
765
  child: Container(
743
766
  padding: const EdgeInsets.symmetric(
744
767
  horizontal: KasySpacing.smd,
745
- vertical: KasySpacing.smd,
768
+ vertical: KasySpacing.sm,
746
769
  ),
747
770
  decoration: BoxDecoration(
748
771
  color: selected ? c.surfaceNeutralSoft : Colors.transparent,
@@ -864,8 +887,11 @@ class _DesktopDetail extends ConsumerWidget {
864
887
  const SizedBox(height: KasySpacing.xl),
865
888
  _settingsGroup([_LogoutRow(onTap: () => confirmLogout(context, ref))]),
866
889
  ],
867
- const SizedBox(height: KasySpacing.xl),
868
- const DeleteUserButton(),
890
+ // Only when there's a real backend account to delete (see mobile layout).
891
+ if (userId != null) ...[
892
+ const SizedBox(height: KasySpacing.xl),
893
+ const DeleteUserButton(),
894
+ ],
869
895
  const SizedBox(height: KasySpacing.xl),
870
896
  const _VersionLabel(),
871
897
  ];
@@ -881,13 +907,18 @@ class _LogoutRow extends StatelessWidget {
881
907
  @override
882
908
  Widget build(BuildContext context) {
883
909
  return KasyHover(
884
- borderRadius: KasyRadius.smBorderRadius,
910
+ // Match the card radius so the full-bleed press fill hugs the rounded
911
+ // corners exactly (this row is the sole child of its card).
912
+ borderRadius: KasyRadius.lgBorderRadius,
885
913
  pressColor: context.colors.error,
886
914
  focusable: true,
887
915
  focusGapColor: context.colors.surface,
888
916
  onTap: onTap,
889
917
  child: Padding(
890
- padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
918
+ padding: const EdgeInsets.symmetric(
919
+ horizontal: KasySpacing.md,
920
+ vertical: KasySpacing.smd,
921
+ ),
891
922
  child: Row(
892
923
  children: [
893
924
  Icon(
@@ -898,7 +929,7 @@ class _LogoutRow extends StatelessWidget {
898
929
  const SizedBox(width: KasySpacing.sm),
899
930
  Text(
900
931
  context.t.settings.logout,
901
- style: context.textTheme.titleSmall?.copyWith(
932
+ style: context.kasyTextTheme.listRowTitle.copyWith(
902
933
  color: context.colors.error,
903
934
  ),
904
935
  ),
@@ -967,12 +998,12 @@ class BiometricSwitcher extends ConsumerWidget {
967
998
  ),
968
999
  Padding(
969
1000
  padding: const EdgeInsets.only(
970
- left: KasyIconSize.rowLeading + KasySpacing.sm,
1001
+ left: KasySpacing.md + KasyIconSize.rowLeading + KasySpacing.sm,
971
1002
  bottom: KasySpacing.xs,
972
1003
  ),
973
1004
  child: Text(
974
1005
  subtitle,
975
- style: context.textTheme.bodySmall?.copyWith(
1006
+ style: context.textTheme.bodyMedium?.copyWith(
976
1007
  color: context.colors.muted,
977
1008
  ),
978
1009
  ),
@@ -1101,7 +1132,10 @@ class ThemeSwitcher extends StatelessWidget {
1101
1132
  ),
1102
1133
  ];
1103
1134
  return Padding(
1104
- padding: const EdgeInsets.symmetric(vertical: KasySpacing.sm),
1135
+ padding: const EdgeInsets.symmetric(
1136
+ horizontal: KasySpacing.md,
1137
+ vertical: KasySpacing.smd,
1138
+ ),
1105
1139
  child: Row(
1106
1140
  children: [
1107
1141
  Icon(
@@ -1113,7 +1147,7 @@ class ThemeSwitcher extends StatelessWidget {
1113
1147
  Expanded(
1114
1148
  child: Text(
1115
1149
  tr.theme_title,
1116
- style: context.textTheme.titleSmall?.copyWith(
1150
+ style: context.kasyTextTheme.listRowTitle.copyWith(
1117
1151
  color: context.colors.onSurface,
1118
1152
  ),
1119
1153
  ),