kasy-cli 1.38.0 → 1.39.1

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 (105) 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/features/notifications/api/device_api.dart +11 -6
  4. package/lib/scaffold/backends/api/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +9 -1
  5. package/lib/scaffold/backends/api/pubspec.yaml.tpl +1 -0
  6. package/lib/scaffold/backends/patch-base-hashes.json +6 -6
  7. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  8. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +3 -0
  9. package/lib/scaffold/backends/supabase/migrations/20240101000012_welcome_decouple_from_push.sql +62 -0
  10. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +11 -6
  11. package/lib/scaffold/backends/supabase/patch/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  12. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +1 -0
  13. package/lib/scaffold/shared/generator-utils.js +12 -6
  14. package/package.json +1 -1
  15. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +23 -23
  16. package/templates/firebase/AGENTS.md +2 -2
  17. package/templates/firebase/DESIGN_SYSTEM.md +23 -8
  18. package/templates/firebase/assets/icons/apple_black.svg +3 -0
  19. package/templates/firebase/assets/icons/apple_white.svg +4 -0
  20. package/templates/firebase/assets/icons/facebook.svg +49 -0
  21. package/templates/firebase/assets/icons/google.svg +1 -0
  22. package/templates/firebase/functions/src/admin/functions.ts +2 -0
  23. package/templates/firebase/functions/src/authentication/functions.ts +13 -7
  24. package/templates/firebase/functions/src/notifications/triggers.ts +6 -2
  25. package/templates/firebase/lib/components/components.dart +5 -2
  26. package/templates/firebase/lib/components/kasy_app_bar.dart +325 -15
  27. package/templates/firebase/lib/components/kasy_card.dart +4 -0
  28. package/templates/firebase/lib/components/kasy_drop_down.dart +584 -0
  29. package/templates/firebase/lib/components/kasy_sidebar.dart +18 -6
  30. package/templates/firebase/lib/components/kasy_tabs.dart +31 -10
  31. package/templates/firebase/lib/components/kasy_text_field.dart +29 -7
  32. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +27 -18
  33. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +34 -16
  34. package/templates/firebase/lib/core/chrome/app_bar_config.dart +214 -0
  35. package/templates/firebase/lib/core/chrome/app_bar_scope.dart +102 -0
  36. package/templates/firebase/lib/core/data/api/user_api.dart +11 -0
  37. package/templates/firebase/lib/core/dev_inspector/dev_inspector_service.dart +55 -15
  38. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +95 -30
  39. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +11 -0
  40. package/templates/firebase/lib/core/states/logout_action.dart +11 -1
  41. package/templates/firebase/lib/core/states/user_state_notifier.dart +28 -1
  42. package/templates/firebase/lib/core/theme/texts.dart +21 -6
  43. package/templates/firebase/lib/core/theme/type_scale.dart +34 -15
  44. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +51 -19
  45. package/templates/firebase/lib/core/web_viewport_scale.dart +66 -36
  46. package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +14 -3
  47. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +1 -1
  48. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +52 -35
  49. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  50. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +18 -8
  51. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +11 -61
  52. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +11 -61
  53. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +7 -5
  54. package/templates/firebase/lib/features/authentication/ui/widgets/social_auth_tile.dart +83 -0
  55. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +4 -4
  56. package/templates/firebase/lib/features/home/home_components_page.dart +253 -125
  57. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +263 -59
  58. package/templates/firebase/lib/features/home/home_feed.dart +2 -2
  59. package/templates/firebase/lib/features/home/home_image_grid.dart +3 -3
  60. package/templates/firebase/lib/features/local_reminders/providers/reminder_notifier.dart +8 -1
  61. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +111 -57
  62. package/templates/firebase/lib/features/notifications/api/device_api.dart +11 -3
  63. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -4
  64. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +7 -56
  65. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +5 -5
  66. package/templates/firebase/lib/features/notifications/ui/widgets/push_permission_banner.dart +163 -0
  67. package/templates/firebase/lib/features/notifications/ui/widgets/web_notifications_bell.dart +2 -2
  68. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +9 -0
  69. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +28 -0
  70. package/templates/firebase/lib/features/onboarding/ui/onboarding_page.dart +51 -12
  71. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +90 -32
  72. package/templates/firebase/lib/features/settings/settings_page.dart +53 -32
  73. package/templates/firebase/lib/features/settings/ui/components/admin/admin_home_widgets.dart +4 -0
  74. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +895 -111
  75. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_api.dart +7 -0
  76. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +445 -233
  77. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +171 -41
  78. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +1 -1
  79. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +9 -1
  80. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +48 -47
  81. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +21 -18
  82. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +25 -10
  83. package/templates/firebase/lib/i18n/en.i18n.json +753 -712
  84. package/templates/firebase/lib/i18n/es.i18n.json +753 -712
  85. package/templates/firebase/lib/i18n/pt.i18n.json +753 -712
  86. package/templates/firebase/lib/main.dart +20 -7
  87. package/templates/firebase/lib/router.dart +32 -26
  88. package/templates/firebase/pubspec.yaml +2 -1
  89. package/templates/firebase/test/admin_shell_chrome_test.dart +11 -5
  90. package/templates/firebase/test/app_bar_config_test.dart +70 -0
  91. package/templates/firebase/test/components/kasy_text_field_height_test.dart +77 -0
  92. package/templates/firebase/test/core/web_viewport_scale_test.dart +23 -16
  93. package/templates/firebase/tool/design_check.dart +9 -0
  94. package/templates/firebase/assets/icons/apple.png +0 -0
  95. package/templates/firebase/assets/icons/facebook.png +0 -0
  96. package/templates/firebase/assets/icons/google.png +0 -0
  97. package/templates/firebase/assets/icons/google_play_games.png +0 -0
  98. package/templates/firebase/lib/components/kasy_web_header.dart +0 -218
  99. package/templates/firebase/lib/core/chrome/web_header_scope.dart +0 -20
  100. package/templates/firebase/lib/features/authentication/ui/components/apple_signin.dart +0 -19
  101. package/templates/firebase/lib/features/authentication/ui/components/google_signin.dart +0 -32
  102. package/templates/firebase/lib/features/authentication/ui/widgets/round_signin.dart +0 -73
  103. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -66
  104. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +0 -179
  105. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +0 -106
@@ -4,18 +4,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
4
4
  import 'package:go_router/go_router.dart';
5
5
  import 'package:kasy_kit/components/components.dart';
6
6
  import 'package:kasy_kit/core/data/api/analytics_api.dart';
7
+ import 'package:kasy_kit/core/rating/models/review.dart';
7
8
  import 'package:kasy_kit/core/rating/providers/rating_repository.dart';
8
9
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
9
10
  import 'package:kasy_kit/core/theme/theme.dart';
10
11
  import 'package:kasy_kit/i18n/translations.g.dart';
11
12
  import 'package:logger/logger.dart';
12
13
 
13
- /// Shows the in-app review dialog. Prefer a stable [context] (not a sheet that
14
+ /// Shows the in-app review funnel. Prefer a stable [context] (not a sheet that
14
15
  /// will be popped before the async work finishes).
15
16
  ///
16
- /// A clean [KasyDialog]: a title, a short message and two stacked actions
17
- /// (write a review / suggest improvements). Dismissing via the dialog's own
18
- /// close button just defers the next ask and keeps the user where they are.
17
+ /// Two-step "rating protection" funnel:
18
+ /// 1. A sentiment gate ("Enjoying the app?") with a positive / negative pick.
19
+ /// 2a. Positive a gold-star [KasyDialog] that sends the user to the store
20
+ /// (App Store / Play Store) to write a public review.
21
+ /// 2b. Negative → the private `/feedback` screen, so an unhappy user vents to
22
+ /// us instead of leaving a low public rating.
23
+ ///
24
+ /// Dismissing any step just defers the next ask and keeps the user where they
25
+ /// are. Returns true once the funnel was shown (regardless of the branch taken).
19
26
  Future<bool> showReviewDialog(
20
27
  BuildContext context,
21
28
  WidgetRef ref, {
@@ -23,7 +30,7 @@ Future<bool> showReviewDialog(
23
30
  }) async {
24
31
  // Store reviews are native-only (App Store / Play Store). In production the
25
32
  // auto prompt never shows on web (nowhere to send the user). But a forced
26
- // call — the admin preview — still shows the dialog on web so its design can
33
+ // call — the admin preview — still shows the funnel on web so its design can
27
34
  // be reviewed there; only the store action won't do anything.
28
35
  if (kIsWeb && !force) {
29
36
  return false;
@@ -46,54 +53,112 @@ Future<bool> showReviewDialog(
46
53
  return false;
47
54
  }
48
55
 
49
- final bool? openFeedback = await showKasyDialog<bool>(
56
+ // Defer the next ask up front, so any outcome (dismiss, positive, negative)
57
+ // counts as "asked" and we don't pester the user again right away.
58
+ await rating.delay();
59
+ analytics.logEvent('rating_funnel_open', {});
60
+ if (!context.mounted) {
61
+ return false;
62
+ }
63
+
64
+ // Step 1 — sentiment gate.
65
+ final bool? enjoying = await _askEnjoying(context, analytics);
66
+ if (enjoying == null) {
67
+ return true; // dismissed without choosing
68
+ }
69
+
70
+ // Step 2b — negative branch: keep the bad review private.
71
+ if (!enjoying) {
72
+ analytics.logEvent('rating_funnel_negative', {});
73
+ if (context.mounted) {
74
+ await context.push('/feedback');
75
+ }
76
+ return true;
77
+ }
78
+
79
+ // Step 2a — positive branch: send the happy user to the store.
80
+ analytics.logEvent('rating_funnel_positive', {});
81
+ if (!context.mounted) {
82
+ return true;
83
+ }
84
+ await _askStoreReview(context, ratingRepository, rating, analytics);
85
+ return true;
86
+ }
87
+
88
+ /// Step 1: the sentiment gate. Returns true (enjoying), false (could be better)
89
+ /// or null (dismissed without choosing).
90
+ Future<bool?> _askEnjoying(BuildContext context, AnalyticsApi analytics) {
91
+ return showKasyDialog<bool>(
50
92
  context: context,
51
- barrierDismissible: false,
52
93
  builder: (dialogContext) {
53
- ratingRepository.delay();
54
- final translations = Translations.of(dialogContext).review_popup;
94
+ final t = Translations.of(dialogContext).review_popup;
55
95
  return KasyDialog(
56
- leadingIcon: KasyIcons.star,
96
+ leadingIcon: KasyIcons.favorite,
57
97
  iconTone: KasyDialogIconTone.info,
58
- title: translations.title,
98
+ title: t.question_title,
59
99
  titleCentered: true,
60
- message: translations.description,
100
+ message: t.question_description,
61
101
  onClose: () {
62
- analytics.logEvent('rating_popup_close', {});
63
- rating.delay();
102
+ analytics.logEvent('rating_funnel_dismiss', {});
64
103
  Navigator.of(dialogContext).pop();
65
104
  },
66
105
  footer: Column(
67
106
  crossAxisAlignment: CrossAxisAlignment.stretch,
68
107
  children: [
69
108
  KasyButton(
70
- label: translations.rate_button,
109
+ label: t.question_positive,
71
110
  expand: true,
72
- onPressed: () {
73
- analytics.logEvent('rating_popup_show', {});
74
- ratingRepository.rate().then((_) => rating.review()).then(
75
- (_) {
76
- if (!dialogContext.mounted) return;
77
- Navigator.of(dialogContext).pop();
78
- },
79
- );
80
- },
111
+ onPressed: () => Navigator.of(dialogContext).pop(true),
81
112
  ),
82
113
  const SizedBox(height: KasySpacing.sm),
83
114
  KasyButton(
84
- label: translations.cancel_button,
115
+ label: t.question_negative,
85
116
  variant: KasyButtonVariant.soft,
86
117
  expand: true,
87
- onPressed: () => Navigator.of(dialogContext).pop(true),
118
+ onPressed: () => Navigator.of(dialogContext).pop(false),
88
119
  ),
89
120
  ],
90
121
  ),
91
122
  );
92
123
  },
93
124
  );
125
+ }
94
126
 
95
- if (openFeedback == true && context.mounted) {
96
- await context.push('/feedback');
97
- }
98
- return true;
127
+ /// Step 2a (positive branch): the celebratory store-review dialog.
128
+ Future<void> _askStoreReview(
129
+ BuildContext context,
130
+ RatingRepository ratingRepository,
131
+ Review rating,
132
+ AnalyticsApi analytics,
133
+ ) {
134
+ return showKasyDialog<void>(
135
+ context: context,
136
+ builder: (dialogContext) {
137
+ final t = Translations.of(dialogContext).review_popup;
138
+ return KasyDialog(
139
+ leadingIcon: KasyIcons.star,
140
+ // Gold/amber star via KasyDialog's default `warning` tone — the design
141
+ // system's amber (#F5A524 light / #F7B750 dark), the natural colour for
142
+ // a rating star.
143
+ title: t.title,
144
+ titleCentered: true,
145
+ message: t.description,
146
+ onClose: () {
147
+ analytics.logEvent('rating_popup_close', {});
148
+ Navigator.of(dialogContext).pop();
149
+ },
150
+ footer: KasyButton(
151
+ label: t.rate_button,
152
+ expand: true,
153
+ onPressed: () {
154
+ analytics.logEvent('rating_popup_show', {});
155
+ ratingRepository.rate().then((_) => rating.review()).then((_) {
156
+ if (!dialogContext.mounted) return;
157
+ Navigator.of(dialogContext).pop();
158
+ });
159
+ },
160
+ ),
161
+ );
162
+ },
163
+ );
99
164
  }
@@ -105,4 +105,15 @@ class SharedPreferencesBuilder implements OnStartService {
105
105
  Future<void> setAttSoftLastAskedAt(DateTime when) async {
106
106
  await prefs.setInt('att_soft_last_asked_at', when.millisecondsSinceEpoch);
107
107
  }
108
+
109
+ /// Whether we've already fired the one-time native push-permission prompt the
110
+ /// first time the user opened the notifications screen. iOS only ever shows
111
+ /// the native prompt once, so we must never auto-fire it more than once.
112
+ bool getPushAutoRequested() {
113
+ return prefs.getBool('push_auto_requested') ?? false;
114
+ }
115
+
116
+ Future<void> setPushAutoRequested(bool value) async {
117
+ await prefs.setBool('push_auto_requested', value);
118
+ }
108
119
  }
@@ -4,6 +4,7 @@ import 'package:kasy_kit/components/components.dart';
4
4
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
5
5
  import 'package:kasy_kit/core/theme/theme.dart';
6
6
  import 'package:kasy_kit/i18n/translations.g.dart';
7
+ import 'package:kasy_kit/router.dart';
7
8
 
8
9
  /// Standardized logout flow — the single source of truth for signing out.
9
10
  ///
@@ -25,5 +26,14 @@ Future<void> confirmLogout(BuildContext context, WidgetRef ref) {
25
26
  // forget: that closed the dialog instantly and left the old screen frozen.)
26
27
  onConfirmAsync: () =>
27
28
  ref.read(userStateNotifierProvider.notifier).onLogout(),
28
- );
29
+ ).whenComplete(() {
30
+ // The confirm dialog is a pageless route on the ROOT navigator, so the
31
+ // redirect that fires when onLogout flips the state to anonymous runs while
32
+ // the dialog still sits on top — go_router keeps the current page (Settings)
33
+ // mounted underneath and the move to /signin never lands. Now that the
34
+ // dialog has popped, re-run the redirect so the (now unauthenticated) user
35
+ // is sent to sign-in. Harmless on cancel: the redirect keeps an
36
+ // authenticated user exactly where they are.
37
+ if (context.mounted) ref.read(goRouterProvider).refresh();
38
+ });
29
39
  }
@@ -187,6 +187,7 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
187
187
  // Biometric lock is a per-account preference, not a device-wide one.
188
188
  // The next user signing in on this install should start without it set.
189
189
  await ref.read(sharedPreferencesProvider).setBiometricEnabled(false);
190
+ await _clearWebGuestPass();
190
191
  // Forget the last bottom-bar tab so the next login lands on the default tab
191
192
  // (Home) instead of wherever the previous account left off.
192
193
  forgetActiveTab();
@@ -285,7 +286,18 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
285
286
  /// It will delete the user account and logout the user
286
287
  Future<void> deleteAccount() async {
287
288
  _stopRoleListener();
288
- final userId = state.user.idOrThrow;
289
+ final userId = state.user.idOrNull;
290
+ // No backend identity (a guest with no account): there is nothing to delete
291
+ // server-side. Just clear the local session so we never throw a generic
292
+ // error that strands the user on Settings — the UI hides the delete button
293
+ // in this case, this is only a safety net.
294
+ if (userId == null) {
295
+ await _authenticationRepository.logout();
296
+ await _clearWebGuestPass();
297
+ forgetActiveTab();
298
+ state = const UserState(user: User.anonymous());
299
+ return;
300
+ }
289
301
  _deviceRepository.removeTokenUpdateListener();
290
302
  try {
291
303
  await _deviceRepository.unregister(userId);
@@ -294,6 +306,7 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
294
306
  }
295
307
  await _userRepository.delete();
296
308
  await _authenticationRepository.logout();
309
+ await _clearWebGuestPass();
297
310
  // Same as onLogout: forget the last bottom-bar tab so the next account that
298
311
  // signs in lands on Home, not wherever the deleted account left off (the
299
312
  // user deletes from Settings, so without this the next login reopens it).
@@ -304,6 +317,20 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
304
317
  // continue as a guest.
305
318
  }
306
319
 
320
+ /// On WEB the auth redirect treats "onboarding done" as "this guest is allowed
321
+ /// to stay" (`ready = hasIdentity || isOnboarded || onboardingDone`). After a
322
+ /// logout / account deletion the flag is still true, so the redirect would
323
+ /// keep the just-signed-out user on the current screen instead of sending them
324
+ /// to /signin — and whether it happened to work depended on leftover browser
325
+ /// state. Clearing it on web makes the session boundary land on /signin every
326
+ /// time. No-op on native, where this flag only skips the (native-only)
327
+ /// onboarding screen and logout already routes correctly via hasIdentity, so
328
+ /// clearing it would wrongly show onboarding again.
329
+ Future<void> _clearWebGuestPass() async {
330
+ if (!kIsWeb) return;
331
+ await ref.read(sharedPreferencesProvider).setOnboardingCompleted(false);
332
+ }
333
+
307
334
  // -------------------------------
308
335
  // ROLE LISTENER
309
336
  // -------------------------------
@@ -147,19 +147,34 @@ class KasyTextTheme extends ThemeExtension<KasyTextTheme> {
147
147
  /// Top-level page title (auth, paywall, full-screen flows). 24 / w700.
148
148
  TextStyle get pageTitle => headlineMedium;
149
149
 
150
- /// Section or master-detail pane title (Settings detail, grouped areas). 16 / w600.
150
+ /// Section or master-detail pane title (Settings detail, grouped areas).
151
+ /// 18 / w600 — a visible step above the content rows it heads.
151
152
  TextStyle get sectionTitle => titleMedium;
152
153
 
153
154
  /// Small header above a grouped list (e.g. "PREFERENCES"). 12 / w600, lightly tracked.
154
155
  TextStyle get sectionLabel =>
155
156
  bodySmall.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5);
156
157
 
157
- /// Primary text of a list / settings row. 14 / w500.
158
+ /// Primary text of a DENSE navigation / list row sidebar, tabs, admin
159
+ /// tables. 14 / w500. For full-width CONTENT rows (Settings, a messaging /
160
+ /// conversation inbox) use [listRowTitle] (16) instead.
158
161
  TextStyle get rowTitle => titleSmall;
159
162
 
160
- /// Secondary / value text in a row (apply a muted colour at the call site). 14 / w400.
163
+ /// Secondary / value text in a dense row (apply a muted colour at the call
164
+ /// site). 14 / w400.
161
165
  TextStyle get rowValue => bodyMedium;
162
166
 
167
+ /// EMPHASIS text of a CONTENT list row — the row's label/title (Settings, and
168
+ /// other iOS-style lists where each row is primary content). 16 / w500. Pairs
169
+ /// with [listRowValue] (14) so the label clearly outranks its value.
170
+ TextStyle get listRowTitle =>
171
+ bodyLarge.copyWith(fontWeight: FontWeight.w500);
172
+
173
+ /// INFO text of a content list row — the value beside the label (apply a muted
174
+ /// colour at the call site). 14 / w400, deliberately one step below
175
+ /// [listRowTitle] so label vs value reads as a hierarchy, not a flat pair.
176
+ TextStyle get listRowValue => bodyMedium;
177
+
163
178
  /// Card title. 14 / w500.
164
179
  TextStyle get cardTitle => titleSmall;
165
180
 
@@ -188,17 +203,17 @@ class KasyTextTheme extends ThemeExtension<KasyTextTheme> {
188
203
  headlineMedium: _fromRamp(FontWeight.w700, KasyTypeScale.heading2, device),
189
204
  headlineSmall: _fromRamp(FontWeight.w600, KasyTypeScale.heading3, device),
190
205
 
191
- // Title → Heading 3 / Heading 4 / Body sm medium.
206
+ // Title → Heading 3 / Heading 4 / Body sm (dense title 14, w500).
192
207
  titleLarge: _fromRamp(FontWeight.w600, KasyTypeScale.heading3, device),
193
208
  titleMedium: _fromRamp(FontWeight.w600, KasyTypeScale.heading4, device),
194
209
  titleSmall: _fromRamp(FontWeight.w500, KasyTypeScale.bodySm, device),
195
210
 
196
- // Body → Body base / Body sm / Body xs.
211
+ // Body → Body base (16) / Body sm (14) / Body xs (12).
197
212
  bodyLarge: _fromRamp(FontWeight.w400, KasyTypeScale.bodyBase, device),
198
213
  bodyMedium: _fromRamp(FontWeight.w400, KasyTypeScale.bodySm, device),
199
214
  bodySmall: _fromRamp(FontWeight.w400, KasyTypeScale.bodyXs, device),
200
215
 
201
- // Label → Button sm / Body xs medium.
216
+ // Label → Body sm (14, w500) / Body xs (12, w500).
202
217
  labelLarge: _fromRamp(FontWeight.w500, KasyTypeScale.bodySm, device),
203
218
  labelMedium: _fromRamp(FontWeight.w500, KasyTypeScale.bodyXs, device),
204
219
  labelSmall: _fromRamp(FontWeight.w500, KasyTypeScale.bodyXs, device),
@@ -2,15 +2,25 @@ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
2
2
 
3
3
  /// Responsive type scale — explicit font size per role, per breakpoint.
4
4
  ///
5
- /// Single source of truth for typography sizes. Instead of a blind global
6
- /// multiplier, every role declares its own size at each breakpoint the way a
7
- /// design tool (Figma variables / modes) or a fluid web scale does it.
5
+ /// Single source of truth for typography sizes, and the ONLY place breakpoints
6
+ /// touch type. There is no global multiplier and no per-component scaling: every
7
+ /// role declares its own size at each breakpoint here, exactly like design-tool
8
+ /// modes (Figma variables) or platform text styles. Components only read the
9
+ /// resolved theme role (`context.textTheme.*`); they never scale text themselves
10
+ /// by width. (The `WebViewportScale` zoom is a separate, web-only UI alignment
11
+ /// fix — it scales the whole canvas, not these type sizes.)
8
12
  ///
9
- /// The professional pattern this follows: **headings are largest on desktop and
10
- /// compress on smaller viewports** (a 36px title would feel huge and wrap badly
11
- /// on a phone), while **body and label text stay constant** for stable,
12
- /// comfortable reading on every device. Desktop is the authored reference;
13
- /// tablet and mobile step the headings down.
13
+ /// The ladder is harmonious and SEMANTIC each role one clear step from the
14
+ /// next, and each maps to a PURPOSE, not a guessed size:
15
+ /// - **Headings & display SCALE with the viewport** (this is where real
16
+ /// responsiveness lives): big on desktop, stepping down on mobile so a hero or
17
+ /// page title never wraps badly on a phone.
18
+ /// - **Body & labels are STABLE across breakpoints** — one comfortable reading
19
+ /// size per role, the Material/Apple model. The body ladder uses MARKED 2px
20
+ /// steps so hierarchy is actually visible: section/card title 18, content-row
21
+ /// title / body 16, secondary / value / nav 14, caption 12. No 1px in-between
22
+ /// levels — a 15-vs-16 difference reads as identical, so it isn't a hierarchy.
23
+ /// A row's LABEL (16) and its VALUE (14) are deliberately different sizes.
14
24
  ///
15
25
  /// To re-tune the whole app's typography, edit the numbers here — nothing else.
16
26
  /// The live ramp is visible under Design System -> Typography (tabs per
@@ -51,27 +61,36 @@ class RampSize {
51
61
  class KasyTypeScale {
52
62
  const KasyTypeScale._();
53
63
 
54
- // Hero / display — large, compress hard on the way down.
64
+ // Hero / display — scale with space: large on desktop, compress hard toward
65
+ // mobile so a hero headline never wraps awkwardly on a phone.
55
66
  static const displayLarge =
56
- RampSize(mobile: 40, tablet: 48, desktop: 57, lineHeightRatio: 64 / 57);
67
+ RampSize(mobile: 36, tablet: 46, desktop: 57, lineHeightRatio: 64 / 57);
57
68
  static const displayMedium =
58
- RampSize(mobile: 34, tablet: 40, desktop: 45, lineHeightRatio: 52 / 45);
69
+ RampSize(mobile: 32, tablet: 38, desktop: 45, lineHeightRatio: 52 / 45);
59
70
 
60
- // Headings — largest on desktop, step down on tablet then mobile.
71
+ // Headings — scale with space. Mobile anchors on the iOS title ladder
72
+ // (Title1 28 / Title2 22 / Title3 20); desktop grows for the wider canvas.
61
73
  static const heading1 =
62
74
  RampSize(mobile: 28, tablet: 32, desktop: 36, lineHeightRatio: 40 / 36);
63
75
  static const heading2 =
64
76
  RampSize(mobile: 22, tablet: 23, desktop: 24, lineHeightRatio: 32 / 24);
65
77
  static const heading3 =
66
- RampSize(mobile: 18, tablet: 19, desktop: 20, lineHeightRatio: 28 / 20);
78
+ RampSize(mobile: 20, tablet: 20, desktop: 20, lineHeightRatio: 28 / 20);
79
+ // Section / card title — 18 (Tailwind text-lg): a visible step above body.
67
80
  static const heading4 =
68
- RampSize(mobile: 16, tablet: 16, desktop: 16, lineHeightRatio: 24 / 16);
81
+ RampSize(mobile: 18, tablet: 18, desktop: 18, lineHeightRatio: 26 / 18);
69
82
 
70
- // Body & labels — constant across breakpoints for stable, comfortable reading.
83
+ // Body & labels — STABLE, with MARKED 2px steps so hierarchy is visible.
84
+ // Mirrors Material's body/label tiers (16 / 14 / 12); no 1px in-between levels
85
+ // (a 15-vs-16 difference reads as identical, so it isn't a hierarchy). Weight
86
+ // and purpose differentiate roles that share a size (Material does the same).
87
+ // Content-row title, body paragraphs, chat message:
71
88
  static const bodyBase =
72
89
  RampSize(mobile: 16, tablet: 16, desktop: 16, lineHeightRatio: 24 / 16);
90
+ // Secondary / value, dense nav row, card title, labels:
73
91
  static const bodySm =
74
92
  RampSize(mobile: 14, tablet: 14, desktop: 14, lineHeightRatio: 20 / 14);
93
+ // Caption, timestamp, fine print:
75
94
  static const bodyXs =
76
95
  RampSize(mobile: 12, tablet: 12, desktop: 12, lineHeightRatio: 16 / 12);
77
96
  }
@@ -32,6 +32,15 @@ final ValueNotifier<bool> webDevicePreviewEnabledNotifier = ValueNotifier<bool>(
32
32
  false,
33
33
  );
34
34
 
35
+ /// True only once the device frame is actually ON SCREEN (not the instant the
36
+ /// toggle flips — the frame takes a moment to build). The web viewport scale
37
+ /// reads THIS, not [webDevicePreviewEnabledNotifier], so the scale drops to
38
+ /// native 1.0 only when the frame is up — otherwise the toggle would flash a
39
+ /// big, unframed, unscaled app while the frame builds.
40
+ final ValueNotifier<bool> webDevicePreviewActiveNotifier = ValueNotifier<bool>(
41
+ false,
42
+ );
43
+
35
44
  // Kit primary/accent (HeroUI blue). The chrome lives above the MaterialApp, so
36
45
  // it can't read context.colors — mirror the design-system value here. Used to
37
46
  // highlight active toggles so it's obvious at a glance what's turned on.
@@ -241,14 +250,55 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
241
250
  if (mounted) setState(() => _controlsVisible = true);
242
251
  });
243
252
  setState(() {}); // rebuild DevicePreview with enabled: true
253
+ // [webDevicePreviewActiveNotifier] flips to true only once the frame is
254
+ // actually built — see [_buildFramedContent] — so the web scale stays put
255
+ // until then (no big-unframed-app flash during the build).
244
256
  } else {
245
257
  _controlsTimer?.cancel();
258
+ // Leaving the frame: restore the web scale immediately.
259
+ webDevicePreviewActiveNotifier.value = false;
246
260
  setState(() => _controlsVisible = false);
247
261
  }
248
262
  }
249
263
 
250
264
  void _onBgChanged() => setState(() {});
251
265
 
266
+ /// Builds the app content placed INSIDE the device frame. DevicePreview calls
267
+ /// this as it brings the framed environment up, so it doubles as the "frame is
268
+ /// on screen now" signal: after this paints we flip
269
+ /// [webDevicePreviewActiveNotifier], which is what lets the web viewport scale
270
+ /// drop to native 1.0 — and only then, so the toggle never shows a flash of
271
+ /// big, unframed, unscaled app while the frame is still building.
272
+ Widget _buildFramedContent(BuildContext context) {
273
+ if (webDevicePreviewEnabledNotifier.value &&
274
+ !webDevicePreviewActiveNotifier.value) {
275
+ WidgetsBinding.instance.addPostFrameCallback((_) {
276
+ if (mounted && webDevicePreviewEnabledNotifier.value) {
277
+ webDevicePreviewActiveNotifier.value = true;
278
+ }
279
+ });
280
+ }
281
+ return ListenableBuilder(
282
+ listenable: _textScaleNotifier,
283
+ builder: (ctx, _) => MediaQuery(
284
+ data: MediaQuery.of(ctx).copyWith(
285
+ textScaler: TextScaler.linear(_textScaleNotifier.value),
286
+ // DevicePreview hard-codes platformBrightness to light inside the
287
+ // simulated frame, which breaks MaterialApp's ThemeMode.system.
288
+ // Forward the real host brightness so "system" tracks the OS.
289
+ platformBrightness:
290
+ WidgetsBinding.instance.platformDispatcher.platformBrightness,
291
+ ),
292
+ child: _DeviceSwitchBridge(
293
+ deviceNotifier: _deviceNotifier,
294
+ frameVisibleNotifier: _frameVisibleNotifier,
295
+ landscapeNotifier: _landscapeNotifier,
296
+ child: widget.child,
297
+ ),
298
+ ),
299
+ );
300
+ }
301
+
252
302
  Future<void> _bootstrap() async {
253
303
  final prefs = await SharedPreferences.getInstance();
254
304
 
@@ -449,25 +499,7 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
449
499
  storage: DevicePreviewStorage.none(),
450
500
  defaultDevice: _currentDevice,
451
501
  devices: [..._iosDevices, ..._androidDevices, ..._iPadDevices],
452
- builder: (context) => ListenableBuilder(
453
- listenable: _textScaleNotifier,
454
- builder: (ctx, _) => MediaQuery(
455
- data: MediaQuery.of(ctx).copyWith(
456
- textScaler: TextScaler.linear(_textScaleNotifier.value),
457
- // DevicePreview hard-codes platformBrightness to light inside the
458
- // simulated frame, which breaks MaterialApp's ThemeMode.system.
459
- // Forward the real host brightness so "system" tracks the OS.
460
- platformBrightness:
461
- WidgetsBinding.instance.platformDispatcher.platformBrightness,
462
- ),
463
- child: _DeviceSwitchBridge(
464
- deviceNotifier: _deviceNotifier,
465
- frameVisibleNotifier: _frameVisibleNotifier,
466
- landscapeNotifier: _landscapeNotifier,
467
- child: widget.child,
468
- ),
469
- ),
470
- ),
502
+ builder: _buildFramedContent,
471
503
  );
472
504
 
473
505
  if (!enabled) return preview;