kasy-cli 1.32.0 → 1.35.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 (169) hide show
  1. package/README.md +1 -1
  2. package/bin/kasy.js +66 -2
  3. package/docs/cli-reference.md +7 -7
  4. package/lib/commands/apple-web.js +222 -0
  5. package/lib/commands/configure.js +3 -91
  6. package/lib/commands/doctor.js +20 -0
  7. package/lib/commands/facebook.js +189 -0
  8. package/lib/commands/new.js +61 -11
  9. package/lib/commands/release-version.js +234 -0
  10. package/lib/commands/update.js +27 -0
  11. package/lib/scaffold/CHANGELOG.json +27 -0
  12. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +24 -0
  13. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api_interface.dart +15 -0
  14. package/lib/scaffold/backends/api/patch/lib/features/authentication/repositories/authentication_repository.dart +27 -0
  15. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  16. package/lib/scaffold/backends/firebase/setup-from-scratch.js +199 -21
  17. package/lib/scaffold/backends/patch-base-hashes.json +66 -0
  18. package/lib/scaffold/backends/supabase/deploy.js +92 -0
  19. package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +2 -0
  20. package/lib/scaffold/backends/supabase/migrations/20240101000007_welcome_notification.sql +36 -23
  21. package/lib/scaffold/backends/supabase/migrations/20240101000014_subscription_trial_end.sql +6 -0
  22. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +92 -3
  23. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/repositories/authentication_repository.dart +36 -0
  24. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  25. package/lib/scaffold/generate.js +53 -4
  26. package/lib/scaffold/shared/generator-utils.js +18 -6
  27. package/lib/utils/apple-web.js +147 -0
  28. package/lib/utils/facebook.js +162 -0
  29. package/lib/utils/i18n/messages-en.js +85 -0
  30. package/lib/utils/i18n/messages-es.js +85 -0
  31. package/lib/utils/i18n/messages-pt.js +85 -0
  32. package/package.json +5 -2
  33. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +73 -0
  34. package/templates/firebase/AGENTS.md +170 -0
  35. package/templates/firebase/CLAUDE.md +16 -0
  36. package/templates/firebase/DESIGN_SYSTEM.md +269 -0
  37. package/templates/firebase/docs/auth-setup.en.md +4 -2
  38. package/templates/firebase/docs/auth-setup.es.md +4 -2
  39. package/templates/firebase/docs/auth-setup.pt.md +4 -2
  40. package/templates/firebase/firebase.json +56 -1
  41. package/templates/firebase/functions/src/authentication/triggers.ts +10 -6
  42. package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +5 -0
  43. package/templates/firebase/functions/src/core/data/entities/user_entity.ts +9 -1
  44. package/templates/firebase/functions/src/core/data/repositories/user_repository.ts +27 -0
  45. package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +3 -0
  46. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +4 -0
  47. package/templates/firebase/lib/components/components.dart +1 -0
  48. package/templates/firebase/lib/components/kasy_alert.dart +0 -1
  49. package/templates/firebase/lib/components/kasy_app_bar.dart +35 -17
  50. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +0 -1
  51. package/templates/firebase/lib/components/kasy_date_picker.dart +0 -5
  52. package/templates/firebase/lib/components/kasy_dialog.dart +0 -1
  53. package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +1 -1
  54. package/templates/firebase/lib/components/kasy_screen.dart +114 -0
  55. package/templates/firebase/lib/components/kasy_sidebar.dart +189 -120
  56. package/templates/firebase/lib/components/kasy_text_area.dart +0 -1
  57. package/templates/firebase/lib/components/kasy_text_field.dart +0 -1
  58. package/templates/firebase/lib/components/kasy_text_field_otp.dart +0 -1
  59. package/templates/firebase/lib/components/kasy_toast.dart +108 -73
  60. package/templates/firebase/lib/core/app_update/app_update_repository.dart +54 -0
  61. package/templates/firebase/lib/core/app_update/app_update_status.dart +14 -0
  62. package/templates/firebase/lib/core/app_update/update_available_sheet.dart +70 -0
  63. package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +40 -3
  64. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +82 -34
  65. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +6 -6
  66. package/templates/firebase/lib/core/bottom_menu/web_url.dart +20 -0
  67. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
  68. package/templates/firebase/lib/core/config/features.dart +5 -0
  69. package/templates/firebase/lib/core/data/api/remote_config_api.dart +38 -1
  70. package/templates/firebase/lib/core/guards/guard.dart +16 -2
  71. package/templates/firebase/lib/core/icons/kasy_icons.dart +3 -0
  72. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +2 -5
  73. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +48 -124
  74. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +14 -0
  75. package/templates/firebase/lib/core/states/components/maybe_show_update_available.dart +32 -0
  76. package/templates/firebase/lib/core/states/logout_action.dart +5 -1
  77. package/templates/firebase/lib/core/states/user_state_notifier.dart +85 -14
  78. package/templates/firebase/lib/core/theme/responsive_text_theme.dart +69 -0
  79. package/templates/firebase/lib/core/theme/texts.dart +90 -57
  80. package/templates/firebase/lib/core/theme/type_scale.dart +77 -0
  81. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +20 -6
  82. package/templates/firebase/lib/core/utils/image_bytes_loader.dart +12 -0
  83. package/templates/firebase/lib/core/utils/image_bytes_loader_io.dart +28 -0
  84. package/templates/firebase/lib/core/utils/image_bytes_loader_web.dart +56 -0
  85. package/templates/firebase/lib/core/web_screen_width.dart +15 -0
  86. package/templates/firebase/lib/core/web_screen_width_io.dart +3 -0
  87. package/templates/firebase/lib/core/web_screen_width_web.dart +10 -0
  88. package/templates/firebase/lib/core/web_viewport_scale.dart +61 -25
  89. package/templates/firebase/lib/core/widgets/focus_visibility.dart +89 -0
  90. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
  91. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -8
  92. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
  93. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +1 -2
  94. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +2 -3
  95. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -2
  96. package/templates/firebase/lib/features/authentication/api/auth_web_support.dart +12 -0
  97. package/templates/firebase/lib/features/authentication/api/auth_web_support_web.dart +25 -0
  98. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +266 -0
  99. package/templates/firebase/lib/features/authentication/api/authentication_api_interface.dart +22 -0
  100. package/templates/firebase/lib/features/authentication/navigation/post_login_navigation.dart +32 -0
  101. package/templates/firebase/lib/features/authentication/providers/phone_auth_notifier.dart +7 -7
  102. package/templates/firebase/lib/features/authentication/providers/signin_state_provider.dart +34 -10
  103. package/templates/firebase/lib/features/authentication/providers/signup_state_provider.dart +2 -2
  104. package/templates/firebase/lib/features/authentication/repositories/authentication_repository.dart +37 -0
  105. package/templates/firebase/lib/features/authentication/repositories/exceptions/authentication_exceptions.dart +8 -0
  106. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +2 -2
  107. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -2
  108. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +80 -15
  109. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +20 -14
  110. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +2 -2
  111. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +2 -2
  112. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -1
  113. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -2
  114. package/templates/firebase/lib/features/home/design_system_page.dart +134 -67
  115. package/templates/firebase/lib/features/home/home_components_page.dart +8 -1
  116. package/templates/firebase/lib/features/home/home_components_preview_page.dart +1 -3
  117. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +186 -56
  118. package/templates/firebase/lib/features/home/home_page.dart +4 -0
  119. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +169 -208
  120. package/templates/firebase/lib/features/notifications/providers/notifications_provider.dart +10 -0
  121. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
  122. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
  123. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
  124. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -4
  125. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +84 -128
  126. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +11 -0
  127. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +30 -3
  128. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +13 -4
  129. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
  130. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
  131. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +2 -1
  132. package/templates/firebase/lib/features/settings/settings_page.dart +152 -11
  133. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +58 -21
  134. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +12 -12
  135. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +1 -4
  136. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
  137. package/templates/firebase/lib/features/settings/ui/components/create_password_sheet.dart +141 -0
  138. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +0 -1
  139. package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +42 -38
  140. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +17 -2
  141. package/templates/firebase/lib/features/subscriptions/api/entities/subscription_entity.dart +3 -0
  142. package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +12 -11
  143. package/templates/firebase/lib/features/subscriptions/repositories/subscription_repository.dart +25 -2
  144. package/templates/firebase/lib/features/subscriptions/ui/component/active_premium_content.dart +319 -143
  145. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +1 -5
  146. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -5
  147. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +2 -5
  148. package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +1 -4
  149. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +1 -2
  150. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +2 -7
  151. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +2 -4
  152. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +1 -4
  153. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +0 -3
  154. package/templates/firebase/lib/i18n/en.i18n.json +54 -7
  155. package/templates/firebase/lib/i18n/es.i18n.json +54 -7
  156. package/templates/firebase/lib/i18n/pt.i18n.json +54 -7
  157. package/templates/firebase/lib/main.dart +11 -2
  158. package/templates/firebase/lib/router.dart +94 -13
  159. package/templates/firebase/pubspec.yaml +1 -1
  160. package/templates/firebase/test/core/data/api/fake_remote_config_api.dart +14 -0
  161. package/templates/firebase/test/core/states/user_state_notifier_test.dart +47 -3
  162. package/templates/firebase/test/core/web_viewport_scale_test.dart +68 -0
  163. package/templates/firebase/test/features/authentication/data/api/auth_api_fake.dart +15 -0
  164. package/templates/firebase/tool/design_check.dart +152 -0
  165. package/templates/firebase/web/index.html +162 -14
  166. package/templates/firebase/assets/images/review.png +0 -0
  167. package/templates/firebase/assets/images/update.png +0 -0
  168. package/templates/firebase/lib/core/guards/user_info_guard.dart +0 -61
  169. package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
@@ -0,0 +1,69 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:kasy_kit/core/theme/texts.dart';
3
+ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
4
+
5
+ /// Republishes the app's typography for the current breakpoint — without
6
+ /// touching a single call site.
7
+ ///
8
+ /// It reads the effective viewport width, picks the [DeviceType], and rebuilds
9
+ /// the Material `textTheme` and the [KasyTextTheme] extension from the
10
+ /// per-breakpoint [KasyTypeScale] ramp. Headings step down from desktop to
11
+ /// mobile; body and labels stay constant. Colours are kept from the base theme
12
+ /// (the ramp is colourless), so every `context.textTheme.*` and
13
+ /// `context.kasyTextTheme.*` below inherits the breakpoint's sizes.
14
+ ///
15
+ /// Placed as the innermost wrapper of the app (inside `WebViewportScale` and
16
+ /// `DevicePreview`), so it reads the *same* width the screens lay out against:
17
+ /// the type bucket always matches the layout bucket. Desktop is the authored
18
+ /// baseline, so it is a pass-through there.
19
+ class ResponsiveTextTheme extends StatelessWidget {
20
+ final Widget child;
21
+
22
+ const ResponsiveTextTheme({super.key, required this.child});
23
+
24
+ @override
25
+ Widget build(BuildContext context) {
26
+ final DeviceType device =
27
+ DeviceType.fromWidth(MediaQuery.sizeOf(context).width);
28
+
29
+ // Desktop is the authored baseline (the base theme built in main.dart) — no
30
+ // rebuild needed.
31
+ if (device != DeviceType.small && device != DeviceType.medium) {
32
+ return child;
33
+ }
34
+
35
+ final ThemeData theme = Theme.of(context);
36
+ final KasyTextTheme ramp = KasyTextTheme.build(device);
37
+ final TextTheme base = theme.textTheme;
38
+
39
+ // Take size/weight/line-height from the ramp, keep colour from the base
40
+ // (the ramp is colourless, so merge preserves the base slot's colour).
41
+ final TextTheme rebuilt = TextTheme(
42
+ displayLarge: base.displayLarge?.merge(ramp.displayLarge),
43
+ displayMedium: base.displayMedium?.merge(ramp.displayMedium),
44
+ displaySmall: base.displaySmall?.merge(ramp.displaySmall),
45
+ headlineLarge: base.headlineLarge?.merge(ramp.headlineLarge),
46
+ headlineMedium: base.headlineMedium?.merge(ramp.headlineMedium),
47
+ headlineSmall: base.headlineSmall?.merge(ramp.headlineSmall),
48
+ titleLarge: base.titleLarge?.merge(ramp.titleLarge),
49
+ titleMedium: base.titleMedium?.merge(ramp.titleMedium),
50
+ titleSmall: base.titleSmall?.merge(ramp.titleSmall),
51
+ bodyLarge: base.bodyLarge?.merge(ramp.bodyLarge),
52
+ bodyMedium: base.bodyMedium?.merge(ramp.bodyMedium),
53
+ bodySmall: base.bodySmall?.merge(ramp.bodySmall),
54
+ labelLarge: base.labelLarge?.merge(ramp.labelLarge),
55
+ labelMedium: base.labelMedium?.merge(ramp.labelMedium),
56
+ labelSmall: base.labelSmall?.merge(ramp.labelSmall),
57
+ );
58
+
59
+ // Replace only the KasyTextTheme extension; keep every other extension.
60
+ final List<ThemeExtension<dynamic>> extensions = theme.extensions.values
61
+ .map((e) => e is KasyTextTheme ? ramp : e)
62
+ .toList();
63
+
64
+ return Theme(
65
+ data: theme.copyWith(textTheme: rebuilt, extensions: extensions),
66
+ child: child,
67
+ );
68
+ }
69
+ }
@@ -1,5 +1,7 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:google_fonts/google_fonts.dart';
3
+ import 'package:kasy_kit/core/theme/type_scale.dart';
4
+ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
3
5
 
4
6
  /// Kasy Design System — Typography Tokens (HeroUI type scale)
5
7
  ///
@@ -32,6 +34,22 @@ class KasyTextTheme extends ThemeExtension<KasyTextTheme> {
32
34
  decoration: decoration,
33
35
  );
34
36
 
37
+ // Builds a role from its responsive [RampSize] at a given [device]: the size
38
+ // (and proportional line-height) come from the breakpoint, the weight from
39
+ // the role. This is how every slot picks up the per-breakpoint type scale.
40
+ static TextStyle _fromRamp(
41
+ FontWeight weight,
42
+ RampSize ramp,
43
+ DeviceType device, {
44
+ TextDecoration? decoration,
45
+ }) =>
46
+ _inter(
47
+ weight,
48
+ ramp.size(device),
49
+ ramp.lineHeight(device),
50
+ decoration: decoration,
51
+ );
52
+
35
53
  // -----------------------------------------------------------------------
36
54
  // HeroUI roles — canonical styles (color applied per-theme downstream)
37
55
  // -----------------------------------------------------------------------
@@ -54,37 +72,9 @@ class KasyTextTheme extends ThemeExtension<KasyTextTheme> {
54
72
  static TextStyle get buttonBase => _inter(FontWeight.w500, 16, 24);
55
73
  static TextStyle get buttonSm => _inter(FontWeight.w500, 14, 20);
56
74
 
57
- // -----------------------------------------------------------------------
58
- // Semantic app roles name the recurring UI text roles so screens stop
59
- // hand-tuning slots with copyWith(fontSize/fontWeight/...). Each composes a
60
- // scale role above; restyle a role app-wide by editing it here once.
61
- // Apply color at the call site (.copyWith(color: ...)); the scale is colorless.
62
- // -----------------------------------------------------------------------
63
-
64
- /// Top-level page title (auth, paywall, full-screen flows). 24 / w700.
65
- static TextStyle get pageTitle => heading2;
66
-
67
- /// Section or master-detail pane title (Settings detail, grouped areas). 16 / w600.
68
- static TextStyle get sectionTitle => heading4;
69
-
70
- /// Small header above a grouped list (e.g. "PREFERENCES"). 12 / w600, lightly tracked.
71
- static TextStyle get sectionLabel =>
72
- _inter(FontWeight.w600, 12, 16).copyWith(letterSpacing: 0.5);
73
-
74
- /// Primary text of a list / settings row. 14 / w500.
75
- static TextStyle get rowTitle => bodySmMedium;
76
-
77
- /// Secondary / value text in a row (apply a muted color at the call site). 14 / w400.
78
- static TextStyle get rowValue => bodySm;
79
-
80
- /// Card title. 14 / w500.
81
- static TextStyle get cardTitle => bodySmMedium;
82
-
83
- /// Card subtitle / supporting line. 12 / w400.
84
- static TextStyle get cardSubtitle => bodyXs;
85
-
86
- /// Caption, hint, version label, footnote. 12 / w400.
87
- static TextStyle get caption => bodyXs;
75
+ // Semantic UI roles (pageTitle, sectionTitle, rowTitle, …) live as INSTANCE
76
+ // getters further down, so they pick up the per-breakpoint scaling. The raw
77
+ // scale roles above stay static (the authored spec / design-system baseline).
88
78
 
89
79
  // -----------------------------------------------------------------------
90
80
  // Display — large hero text (extends the HeroUI scale upward in Inter)
@@ -143,35 +133,78 @@ class KasyTextTheme extends ThemeExtension<KasyTextTheme> {
143
133
  required this.primary,
144
134
  });
145
135
 
146
- factory KasyTextTheme.build() {
136
+ // -----------------------------------------------------------------------
137
+ // Semantic app roles — INSTANCE getters (responsive).
138
+ //
139
+ // These mirror the static roles above, but derive from this instance's
140
+ // fields, so they pick up the per-breakpoint scaling applied by
141
+ // [ResponsiveTextTheme]. Prefer `context.kasyTextTheme.<role>` on screens
142
+ // when you need a role that scales (the static `KasyTextTheme.<role>` stays
143
+ // fixed at the desktop baseline — fine for specs/docs, not for live UI).
144
+ // Apply colour at the call site; the scale is colourless.
145
+ // -----------------------------------------------------------------------
146
+
147
+ /// Top-level page title (auth, paywall, full-screen flows). 24 / w700.
148
+ TextStyle get pageTitle => headlineMedium;
149
+
150
+ /// Section or master-detail pane title (Settings detail, grouped areas). 16 / w600.
151
+ TextStyle get sectionTitle => titleMedium;
152
+
153
+ /// Small header above a grouped list (e.g. "PREFERENCES"). 12 / w600, lightly tracked.
154
+ TextStyle get sectionLabel =>
155
+ bodySmall.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5);
156
+
157
+ /// Primary text of a list / settings row. 14 / w500.
158
+ TextStyle get rowTitle => titleSmall;
159
+
160
+ /// Secondary / value text in a row (apply a muted colour at the call site). 14 / w400.
161
+ TextStyle get rowValue => bodyMedium;
162
+
163
+ /// Card title. 14 / w500.
164
+ TextStyle get cardTitle => titleSmall;
165
+
166
+ /// Card subtitle / supporting line. 12 / w400.
167
+ TextStyle get cardSubtitle => bodySmall;
168
+
169
+ /// Caption, hint, version label, footnote. 12 / w400.
170
+ TextStyle get caption => bodySmall;
171
+
172
+ /// Builds the full text theme for a given [device].
173
+ ///
174
+ /// Each slot resolves its size from the responsive [KasyTypeScale] ramp at the
175
+ /// breakpoint: headings step down from desktop to mobile, body and labels stay
176
+ /// constant. Defaults to desktop ([DeviceType.large]) so the base theme built
177
+ /// once in `main.dart` is the authored reference; [ResponsiveTextTheme]
178
+ /// rebuilds it per breakpoint below the MediaQuery.
179
+ factory KasyTextTheme.build([DeviceType device = DeviceType.large]) {
147
180
  return KasyTextTheme(
148
- // Display — hero text in Inter, neutral tracking.
149
- displayLarge: _inter(FontWeight.w800, 57, 64),
150
- displayMedium: _inter(FontWeight.w800, 45, 52),
151
- displaySmall: heading1, // 36/40
152
-
153
- // Headline → HeroUI Heading 1–3.
154
- headlineLarge: heading1,
155
- headlineMedium: heading2,
156
- headlineSmall: heading3,
157
-
158
- // Title → HeroUI Heading 3 / Heading 4 / Body sm medium.
159
- titleLarge: heading3,
160
- titleMedium: heading4,
161
- titleSmall: bodySmMedium,
162
-
163
- // Body → HeroUI Body base / Body sm / Body xs.
164
- bodyLarge: bodyBase,
165
- bodyMedium: bodySm,
166
- bodySmall: bodyXs,
167
-
168
- // Label → HeroUI Button sm / Body xs medium.
169
- labelLarge: buttonSm,
170
- labelMedium: bodyXsMedium,
171
- labelSmall: bodyXsMedium,
181
+ // Display — hero text.
182
+ displayLarge: _fromRamp(FontWeight.w800, KasyTypeScale.displayLarge, device),
183
+ displayMedium: _fromRamp(FontWeight.w800, KasyTypeScale.displayMedium, device),
184
+ displaySmall: _fromRamp(FontWeight.w800, KasyTypeScale.heading1, device),
185
+
186
+ // Headline → Heading 1–3.
187
+ headlineLarge: _fromRamp(FontWeight.w800, KasyTypeScale.heading1, device),
188
+ headlineMedium: _fromRamp(FontWeight.w700, KasyTypeScale.heading2, device),
189
+ headlineSmall: _fromRamp(FontWeight.w600, KasyTypeScale.heading3, device),
190
+
191
+ // Title → Heading 3 / Heading 4 / Body sm medium.
192
+ titleLarge: _fromRamp(FontWeight.w600, KasyTypeScale.heading3, device),
193
+ titleMedium: _fromRamp(FontWeight.w600, KasyTypeScale.heading4, device),
194
+ titleSmall: _fromRamp(FontWeight.w500, KasyTypeScale.bodySm, device),
195
+
196
+ // Body → Body base / Body sm / Body xs.
197
+ bodyLarge: _fromRamp(FontWeight.w400, KasyTypeScale.bodyBase, device),
198
+ bodyMedium: _fromRamp(FontWeight.w400, KasyTypeScale.bodySm, device),
199
+ bodySmall: _fromRamp(FontWeight.w400, KasyTypeScale.bodyXs, device),
200
+
201
+ // Label → Button sm / Body xs medium.
202
+ labelLarge: _fromRamp(FontWeight.w500, KasyTypeScale.bodySm, device),
203
+ labelMedium: _fromRamp(FontWeight.w500, KasyTypeScale.bodyXs, device),
204
+ labelSmall: _fromRamp(FontWeight.w500, KasyTypeScale.bodyXs, device),
172
205
 
173
206
  // Legacy primary.
174
- primary: bodyBase,
207
+ primary: _fromRamp(FontWeight.w400, KasyTypeScale.bodyBase, device),
175
208
  );
176
209
  }
177
210
 
@@ -0,0 +1,77 @@
1
+ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
2
+
3
+ /// Responsive type scale — explicit font size per role, per breakpoint.
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.
8
+ ///
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.
14
+ ///
15
+ /// To re-tune the whole app's typography, edit the numbers here — nothing else.
16
+ /// The live ramp is visible under Design System -> Typography (tabs per
17
+ /// breakpoint).
18
+ class RampSize {
19
+ /// Phone (< 768).
20
+ final double mobile;
21
+
22
+ /// Tablet (768-1024).
23
+ final double tablet;
24
+
25
+ /// Desktop (>= 1024) — the authored reference.
26
+ final double desktop;
27
+
28
+ /// line-height / font-size. Kept constant across breakpoints so the vertical
29
+ /// rhythm scales together with the font size.
30
+ final double lineHeightRatio;
31
+
32
+ const RampSize({
33
+ required this.mobile,
34
+ required this.tablet,
35
+ required this.desktop,
36
+ required this.lineHeightRatio,
37
+ });
38
+
39
+ /// The font size (logical px) for [device].
40
+ double size(DeviceType device) => switch (device) {
41
+ DeviceType.small => mobile,
42
+ DeviceType.medium => tablet,
43
+ DeviceType.large || DeviceType.xlarge => desktop,
44
+ };
45
+
46
+ /// The line height (logical px) for [device].
47
+ double lineHeight(DeviceType device) => size(device) * lineHeightRatio;
48
+ }
49
+
50
+ /// The Kasy type scale: every typographic role's size across the 3 breakpoints.
51
+ class KasyTypeScale {
52
+ const KasyTypeScale._();
53
+
54
+ // Hero / display — large, compress hard on the way down.
55
+ static const displayLarge =
56
+ RampSize(mobile: 40, tablet: 48, desktop: 57, lineHeightRatio: 64 / 57);
57
+ static const displayMedium =
58
+ RampSize(mobile: 34, tablet: 40, desktop: 45, lineHeightRatio: 52 / 45);
59
+
60
+ // Headings — largest on desktop, step down on tablet then mobile.
61
+ static const heading1 =
62
+ RampSize(mobile: 28, tablet: 32, desktop: 36, lineHeightRatio: 40 / 36);
63
+ static const heading2 =
64
+ RampSize(mobile: 22, tablet: 23, desktop: 24, lineHeightRatio: 32 / 24);
65
+ static const heading3 =
66
+ RampSize(mobile: 18, tablet: 19, desktop: 20, lineHeightRatio: 28 / 20);
67
+ static const heading4 =
68
+ RampSize(mobile: 16, tablet: 16, desktop: 16, lineHeightRatio: 24 / 16);
69
+
70
+ // Body & labels — constant across breakpoints for stable, comfortable reading.
71
+ static const bodyBase =
72
+ RampSize(mobile: 16, tablet: 16, desktop: 16, lineHeightRatio: 24 / 16);
73
+ static const bodySm =
74
+ RampSize(mobile: 14, tablet: 14, desktop: 14, lineHeightRatio: 20 / 14);
75
+ static const bodyXs =
76
+ RampSize(mobile: 12, tablet: 12, desktop: 12, lineHeightRatio: 16 / 12);
77
+ }
@@ -2,17 +2,31 @@ import 'package:flutter/material.dart';
2
2
  import 'package:web/web.dart' as web;
3
3
 
4
4
  void syncWebBackgroundColor(Brightness brightness) {
5
- final color = brightness == Brightness.dark ? '#000000' : '#FFFFFF';
6
- (web.document.documentElement as web.HTMLElement?)
7
- ?.style
8
- .setProperty('background-color', color);
9
- web.document.body?.style.setProperty('background-color', color);
5
+ final bool dark = brightness == Brightness.dark;
6
+ // iOS Safari tints its status bar / address bar with the page's background
7
+ // colour once the Flutter canvas covers the viewport (it ignores the
8
+ // theme-color meta there). It samples whichever element fills the top of the
9
+ // viewport — the flutter-view host, not just <body> — so we paint <html>,
10
+ // <body> AND the Flutter host chain with `surface` (the app bar colour). That
11
+ // way the Safari bars match the app bar with no seam, no matter which element
12
+ // Safari reads. The app's own canvas draws over these, so the page background
13
+ // colour only ever shows in the browser chrome.
14
+ final String chromeColor = dark ? '#18181B' : '#FFFFFF';
15
+
16
+ // Keep the <html data-theme> attribute (set pre-boot by the index.html script)
17
+ // in sync, so a later reload / hot restart paints the splash + Safari chrome
18
+ // with the theme currently chosen in the app.
19
+ final html = web.document.documentElement as web.HTMLElement?;
20
+ html?.setAttribute('data-theme', dark ? 'dark' : 'light');
21
+ html?.style.setProperty('background-color', chromeColor);
22
+ web.document.body?.style.setProperty('background-color', chromeColor);
23
+
10
24
  final views = web.document.querySelectorAll(
11
25
  'flutter-view, flt-glass-pane, flt-scene-host',
12
26
  );
13
27
  for (var i = 0; i < views.length; i++) {
14
28
  (views.item(i) as web.HTMLElement?)
15
29
  ?.style
16
- .setProperty('background-color', color);
30
+ .setProperty('background-color', chromeColor);
17
31
  }
18
32
  }
@@ -0,0 +1,12 @@
1
+ /// Loads raw image bytes from a remote URL, choosing a platform-specific
2
+ /// strategy so social-profile photos (e.g. Google) import reliably:
3
+ /// - native: a direct HTTP download (see `image_bytes_loader_io.dart`),
4
+ /// - web: an `<img>` + canvas read that avoids the CDN throttling that hits
5
+ /// programmatic `fetch()`/XHR (see `image_bytes_loader_web.dart`).
6
+ ///
7
+ /// Both implementations expose the same `loadImageBytes(url)` entry point and
8
+ /// return null (or rethrow) on failure so the caller can keep its fallback.
9
+ library;
10
+
11
+ export 'image_bytes_loader_io.dart'
12
+ if (dart.library.js_interop) 'image_bytes_loader_web.dart';
@@ -0,0 +1,28 @@
1
+ import 'package:dio/dio.dart';
2
+
3
+ /// Downloads image bytes on native platforms.
4
+ ///
5
+ /// Native has no browser fetch throttling, so a direct request with a short
6
+ /// retry on transient errors (HTTP 429/503, timeouts) is reliable. Returns the
7
+ /// raw bytes, or rethrows after exhausting retries so the caller can fall back.
8
+ Future<List<int>?> loadImageBytes(String url) async {
9
+ const delays = [Duration(milliseconds: 600), Duration(seconds: 2)];
10
+ for (var attempt = 0; ; attempt++) {
11
+ try {
12
+ final response = await Dio().get<List<int>>(
13
+ url,
14
+ options: Options(responseType: ResponseType.bytes),
15
+ );
16
+ return response.data;
17
+ } on DioException catch (e) {
18
+ final status = e.response?.statusCode;
19
+ final transient = status == 429 ||
20
+ status == 503 ||
21
+ e.type == DioExceptionType.connectionTimeout ||
22
+ e.type == DioExceptionType.receiveTimeout ||
23
+ e.type == DioExceptionType.connectionError;
24
+ if (!transient || attempt >= delays.length) rethrow;
25
+ await Future<void>.delayed(delays[attempt]);
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,56 @@
1
+ import 'dart:async';
2
+ import 'dart:js_interop';
3
+
4
+ import 'package:web/web.dart' as web;
5
+
6
+ /// Downloads image bytes on web by loading the URL through an `<img>` element
7
+ /// and reading it back from a canvas.
8
+ ///
9
+ /// Browsers serve `<img>` requests reliably, while some CDNs (notably Google's
10
+ /// `lh3.googleusercontent.com`) throttle programmatic `fetch()`/XHR with HTTP
11
+ /// 429 - which is what an HTTP client like dio uses. The image is requested with
12
+ /// CORS (`crossOrigin = 'anonymous'`) so the canvas is not tainted and its
13
+ /// pixels can be exported. Returns null on any failure, so the caller keeps its
14
+ /// fallback avatar.
15
+ Future<List<int>?> loadImageBytes(String url) async {
16
+ final image = web.HTMLImageElement()..crossOrigin = 'anonymous';
17
+ final loaded = Completer<bool>();
18
+ image.addEventListener(
19
+ 'load',
20
+ (web.Event _) {
21
+ if (!loaded.isCompleted) loaded.complete(true);
22
+ }.toJS,
23
+ );
24
+ image.addEventListener(
25
+ 'error',
26
+ (web.Event _) {
27
+ if (!loaded.isCompleted) loaded.complete(false);
28
+ }.toJS,
29
+ );
30
+ image.src = url;
31
+
32
+ final ok = await loaded.future
33
+ .timeout(const Duration(seconds: 10), onTimeout: () => false);
34
+ if (!ok || image.naturalWidth == 0) return null;
35
+
36
+ final canvas = web.HTMLCanvasElement()
37
+ ..width = image.naturalWidth
38
+ ..height = image.naturalHeight;
39
+ final context = canvas.getContext('2d') as web.CanvasRenderingContext2D?;
40
+ if (context == null) return null;
41
+ context.drawImage(image, 0, 0);
42
+
43
+ final blobReady = Completer<web.Blob?>();
44
+ canvas.toBlob(
45
+ (web.Blob? blob) {
46
+ blobReady.complete(blob);
47
+ }.toJS,
48
+ 'image/jpeg',
49
+ 0.92.toJS,
50
+ );
51
+ final blob = await blobReady.future;
52
+ if (blob == null) return null;
53
+
54
+ final buffer = await blob.arrayBuffer().toDart;
55
+ return buffer.toDart.asUint8List();
56
+ }
@@ -0,0 +1,15 @@
1
+ /// Reads the physical screen width (logical/CSS px) so the desktop web scale can
2
+ /// compensate for high OS display scaling WITHOUT reacting to mere window resize.
3
+ ///
4
+ /// The browser window width (what the layout uses) changes whenever the user
5
+ /// drags the window narrower — using it to decide the scale made the whole UI
6
+ /// shrink unnecessarily. The *screen* width, on the other hand, only shrinks when
7
+ /// the OS display scale goes up (Windows 150%, Mac scaled modes), which is exactly
8
+ /// when the compensation is actually wanted.
9
+ ///
10
+ /// Both implementations expose `currentScreenWidth()`; it returns null off the web
11
+ /// (where the scaling path is never reached anyway).
12
+ library;
13
+
14
+ export 'web_screen_width_io.dart'
15
+ if (dart.library.js_interop) 'web_screen_width_web.dart';
@@ -0,0 +1,3 @@
1
+ /// Non-web stub: there is no browser screen off the web, and the scaling path is
2
+ /// gated on [kIsWeb] anyway, so this always returns null.
3
+ double? currentScreenWidth() => null;
@@ -0,0 +1,10 @@
1
+ import 'package:web/web.dart' as web;
2
+
3
+ /// The screen width in logical (CSS) px. This shrinks when the OS display scale
4
+ /// goes up (Windows 150%, Mac scaled modes) but does NOT change when the user
5
+ /// merely resizes the browser window — exactly the signal the desktop scale
6
+ /// compensation needs. Returns null if the value is unavailable (0/invalid).
7
+ double? currentScreenWidth() {
8
+ final int width = web.window.screen.width;
9
+ return width > 0 ? width.toDouble() : null;
10
+ }
@@ -1,32 +1,73 @@
1
1
  import 'package:flutter/foundation.dart' show kIsWeb;
2
2
  import 'package:flutter/widgets.dart';
3
+ import 'package:kasy_kit/core/web_screen_width.dart';
3
4
 
4
- /// Maximum render scale applied to the app on web (used on wide viewports).
5
+ /// Maximum render scale applied to the app on web (desktop breakpoint only).
5
6
  ///
6
7
  /// Flutter web tends to render ~10% larger than equivalent HTML apps at the
7
8
  /// browser's 100% zoom, so the whole UI feels oversized on desktop. `0.95`
8
9
  /// brings it to the proportion the design targets (i.e. what 95% zoom looked
9
- /// like) without the user having to touch the browser zoom. On narrower
10
- /// viewports the effective scale is reduced further (see [kWebViewportScaleTargetWidth]).
10
+ /// like) without the user having to touch the browser zoom. It acts as the cap:
11
+ /// a desktop whose logical width is below the design target (high OS scale)
12
+ /// reduces it further to pin the layout (see [kWebViewportScaleTargetWidth]).
13
+ ///
14
+ /// Mobile and tablet web are deliberately left at 1.0 — same as native and as the
15
+ /// device preview, so a developer previewing a phone/tablet sees exactly what the
16
+ /// native build renders. NATIVE is never scaled: the mechanism is gated on
17
+ /// [kIsWeb], so iOS/Android/macOS/Windows apps render at 1.0 and keep respecting
18
+ /// the user's system text-size (accessibility).
11
19
  const double kWebViewportScale = 0.95;
12
20
 
13
21
  /// Design target width (logical px) the desktop shell is laid out against.
14
22
  ///
15
- /// A high-DPI display with OS scaling (Windows at 125/150/175%) reports a
16
- /// smaller logical viewport width than a Mac at the same physical size, so a
17
- /// fixed [kWebViewportScale] left the shell laid out narrower than the design
18
- /// target and it looked cropped (the user had to Ctrl-minus). Scaling by
19
- /// `width / kWebViewportScaleTargetWidth` instead pins the logical layout width
20
- /// to this target on those displays, so Mac and Windows render the same.
23
+ /// A display with high OS scaling (Windows at 125/150/175%, or a Mac in a scaled
24
+ /// "more space"/"larger text" mode) reports a smaller logical SCREEN width, so the
25
+ /// `0.95` baseline alone left the shell cramped/cropped (the user had to
26
+ /// Ctrl-minus). When the screen is below this target the scale drops further
27
+ /// (`screenWidth / kWebViewportScaleTargetWidth`) so the full design still fits.
28
+ /// Compared against the SCREEN width, not the window width see
29
+ /// [webViewportEffectiveScale].
21
30
  const double kWebViewportScaleTargetWidth = 1280;
22
31
 
23
- /// Minimum real viewport width (logical px) at which the web scale kicks in.
32
+ /// Viewport width (logical px) where the desktop shell begins.
33
+ ///
34
+ /// Below it the layout is mobile/tablet (web renders at natural 1.0); at and above
35
+ /// it the desktop scale applies. Uses the WINDOW width (the layout follows the
36
+ /// window). Tied to the responsive system's desktop breakpoint so they stay in sync.
37
+ const double kWebViewportScaleDesktopBreakpoint = 1024; // DeviceType.large.breakpoint
38
+
39
+ /// Effective web render scale (pure math, unit-testable — see
40
+ /// web_viewport_scale_test.dart).
41
+ ///
42
+ /// [windowWidth] is the browser window width (drives the desktop breakpoint, since
43
+ /// the layout follows the window). [screenWidth] is the physical screen width in
44
+ /// logical px (null = unknown/native).
24
45
  ///
25
- /// The "oversized" problem only shows up on tablet/desktop layouts. On mobile
26
- /// web (a narrow browser) the app should render at its natural size — exactly
27
- /// like the native iOS/Android build, which never scales. Tied to the tablet
28
- /// breakpoint so the rule stays in sync with the rest of the responsive system.
29
- const double kWebViewportScaleMinWidth = 768; // DeviceType.medium.breakpoint
46
+ /// Returns 1.0 below the desktop breakpoint ([kWebViewportScaleDesktopBreakpoint]):
47
+ /// tablet/phone web render at natural size same as native and the device preview,
48
+ /// so previewing a phone/tablet shows what the native build does.
49
+ ///
50
+ /// On desktop it returns the flat [maxScale] cap (0.95) and only drops BELOW it
51
+ /// when the SCREEN is small (high OS scale), via
52
+ /// `screenWidth / kWebViewportScaleTargetWidth`. Keying off the screen — not the
53
+ /// window — is the whole point: merely resizing the browser window narrower does
54
+ /// NOT shrink the UI (the layout just reflows); the extra shrink happens only when
55
+ /// the screen itself is cramped, which is what the compensation is for. With
56
+ /// [screenWidth] null (native, or web before the screen is known) it stays at the
57
+ /// flat cap.
58
+ ///
59
+ /// This is web-only semantics: native never reaches the scaling path (the
60
+ /// [WebViewportScale] widget short-circuits when not on web, via [kIsWeb]), so
61
+ /// iOS/Android and macOS/Windows apps always render at 1.0.
62
+ double webViewportEffectiveScale(
63
+ double windowWidth, {
64
+ double? screenWidth,
65
+ double maxScale = kWebViewportScale,
66
+ }) {
67
+ if (windowWidth < kWebViewportScaleDesktopBreakpoint) return 1.0;
68
+ final double basis = screenWidth ?? double.infinity;
69
+ return (basis / kWebViewportScaleTargetWidth).clamp(0.5, maxScale);
70
+ }
30
71
 
31
72
  /// Renders [child] uniformly scaled by [scale] on web (no-op elsewhere).
32
73
  ///
@@ -52,16 +93,11 @@ class WebViewportScale extends StatelessWidget {
52
93
  Widget build(BuildContext context) {
53
94
  if (!kIsWeb || scale == 1.0) return child;
54
95
  final MediaQueryData mq = MediaQuery.of(context);
55
- // Mobile web (narrow browser) renders at its natural size, just like the
56
- // native build. The scale only applies from the tablet breakpoint up.
57
- if (mq.size.width < kWebViewportScaleMinWidth) return child;
58
- // Width-aware scale: on a wide viewport keep [scale] (0.95); on a high-DPI
59
- // display with OS scaling the browser reports a smaller logical width, so
60
- // scale down just enough to lay the shell out at the design target width
61
- // instead of cropping it. Mac (wide) stays at 0.95; Windows at 125/150/175%
62
- // shrinks proportionally so both look identical.
63
- final double effectiveScale =
64
- (mq.size.width / kWebViewportScaleTargetWidth).clamp(0.5, scale);
96
+ final double effectiveScale = webViewportEffectiveScale(
97
+ mq.size.width,
98
+ screenWidth: currentScreenWidth(),
99
+ maxScale: scale,
100
+ );
65
101
  if (effectiveScale == 1.0) return child;
66
102
  final Size logicalSize = Size(
67
103
  mq.size.width / effectiveScale,