kasy-cli 1.34.0 → 1.36.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 (141) hide show
  1. package/README.md +1 -1
  2. package/bin/kasy.js +24 -2
  3. package/docs/cli-reference.md +7 -7
  4. package/lib/commands/new.js +11 -9
  5. package/lib/commands/release-version.js +234 -0
  6. package/lib/commands/update.js +27 -0
  7. package/lib/scaffold/CHANGELOG.json +18 -0
  8. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +24 -0
  9. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api_interface.dart +15 -0
  10. package/lib/scaffold/backends/api/patch/lib/features/authentication/repositories/authentication_repository.dart +27 -0
  11. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  12. package/lib/scaffold/backends/firebase/setup-from-scratch.js +35 -21
  13. package/lib/scaffold/backends/patch-base-hashes.json +66 -0
  14. package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +2 -0
  15. package/lib/scaffold/backends/supabase/migrations/20240101000007_welcome_notification.sql +36 -23
  16. package/lib/scaffold/backends/supabase/migrations/20240101000014_subscription_trial_end.sql +6 -0
  17. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +82 -3
  18. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/repositories/authentication_repository.dart +36 -0
  19. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  20. package/lib/scaffold/generate.js +53 -4
  21. package/lib/utils/i18n/messages-en.js +23 -0
  22. package/lib/utils/i18n/messages-es.js +23 -0
  23. package/lib/utils/i18n/messages-pt.js +23 -0
  24. package/package.json +5 -2
  25. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +73 -0
  26. package/templates/firebase/AGENTS.md +83 -0
  27. package/templates/firebase/DESIGN_SYSTEM.md +37 -2
  28. package/templates/firebase/docs/auth-setup.en.md +2 -0
  29. package/templates/firebase/docs/auth-setup.es.md +2 -0
  30. package/templates/firebase/docs/auth-setup.pt.md +2 -0
  31. package/templates/firebase/firebase.json +56 -1
  32. package/templates/firebase/functions/src/authentication/triggers.ts +10 -6
  33. package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +5 -0
  34. package/templates/firebase/functions/src/core/data/entities/user_entity.ts +9 -1
  35. package/templates/firebase/functions/src/core/data/repositories/user_repository.ts +27 -0
  36. package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +3 -0
  37. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +4 -0
  38. package/templates/firebase/lib/components/kasy_alert.dart +0 -1
  39. package/templates/firebase/lib/components/kasy_app_bar.dart +31 -16
  40. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +0 -1
  41. package/templates/firebase/lib/components/kasy_date_picker.dart +0 -5
  42. package/templates/firebase/lib/components/kasy_dialog.dart +0 -1
  43. package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +1 -1
  44. package/templates/firebase/lib/components/kasy_sidebar.dart +215 -178
  45. package/templates/firebase/lib/components/kasy_text_area.dart +0 -1
  46. package/templates/firebase/lib/components/kasy_text_field.dart +0 -1
  47. package/templates/firebase/lib/components/kasy_text_field_otp.dart +0 -1
  48. package/templates/firebase/lib/components/kasy_toast.dart +107 -41
  49. package/templates/firebase/lib/core/app_update/app_update_repository.dart +54 -0
  50. package/templates/firebase/lib/core/app_update/app_update_status.dart +14 -0
  51. package/templates/firebase/lib/core/app_update/update_available_sheet.dart +70 -0
  52. package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +40 -3
  53. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +82 -34
  54. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +6 -6
  55. package/templates/firebase/lib/core/bottom_menu/web_url.dart +20 -0
  56. package/templates/firebase/lib/core/data/api/remote_config_api.dart +38 -1
  57. package/templates/firebase/lib/core/guards/guard.dart +16 -2
  58. package/templates/firebase/lib/core/icons/kasy_icons.dart +3 -0
  59. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +2 -5
  60. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +5 -3
  61. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +14 -0
  62. package/templates/firebase/lib/core/states/components/maybe_show_update_available.dart +32 -0
  63. package/templates/firebase/lib/core/states/logout_action.dart +5 -1
  64. package/templates/firebase/lib/core/states/user_state_notifier.dart +85 -14
  65. package/templates/firebase/lib/core/theme/responsive_text_theme.dart +69 -0
  66. package/templates/firebase/lib/core/theme/texts.dart +90 -57
  67. package/templates/firebase/lib/core/theme/type_scale.dart +77 -0
  68. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +20 -6
  69. package/templates/firebase/lib/core/utils/image_bytes_loader.dart +12 -0
  70. package/templates/firebase/lib/core/utils/image_bytes_loader_io.dart +28 -0
  71. package/templates/firebase/lib/core/utils/image_bytes_loader_web.dart +56 -0
  72. package/templates/firebase/lib/core/web_screen_width.dart +15 -0
  73. package/templates/firebase/lib/core/web_screen_width_io.dart +3 -0
  74. package/templates/firebase/lib/core/web_screen_width_web.dart +10 -0
  75. package/templates/firebase/lib/core/web_viewport_scale.dart +61 -25
  76. package/templates/firebase/lib/core/widgets/focus_visibility.dart +89 -0
  77. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +1 -2
  78. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +1 -2
  79. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +2 -3
  80. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -2
  81. package/templates/firebase/lib/features/authentication/api/auth_web_support.dart +12 -0
  82. package/templates/firebase/lib/features/authentication/api/auth_web_support_web.dart +25 -0
  83. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +205 -0
  84. package/templates/firebase/lib/features/authentication/api/authentication_api_interface.dart +22 -0
  85. package/templates/firebase/lib/features/authentication/navigation/post_login_navigation.dart +32 -0
  86. package/templates/firebase/lib/features/authentication/providers/phone_auth_notifier.dart +7 -7
  87. package/templates/firebase/lib/features/authentication/providers/signin_state_provider.dart +34 -10
  88. package/templates/firebase/lib/features/authentication/providers/signup_state_provider.dart +2 -2
  89. package/templates/firebase/lib/features/authentication/repositories/authentication_repository.dart +37 -0
  90. package/templates/firebase/lib/features/authentication/repositories/exceptions/authentication_exceptions.dart +8 -0
  91. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +2 -2
  92. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -2
  93. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +59 -0
  94. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +2 -2
  95. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +2 -2
  96. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -1
  97. package/templates/firebase/lib/features/home/design_system_page.dart +134 -67
  98. package/templates/firebase/lib/features/home/home_components_page.dart +4 -3
  99. package/templates/firebase/lib/features/home/home_components_preview_page.dart +1 -3
  100. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +226 -105
  101. package/templates/firebase/lib/features/home/home_page.dart +4 -0
  102. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +8 -3
  103. package/templates/firebase/lib/features/notifications/providers/notifications_provider.dart +10 -0
  104. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -1
  105. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +8 -3
  106. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +11 -0
  107. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +30 -3
  108. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +13 -4
  109. package/templates/firebase/lib/features/settings/settings_page.dart +152 -11
  110. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +43 -15
  111. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +12 -12
  112. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +1 -4
  113. package/templates/firebase/lib/features/settings/ui/components/create_password_sheet.dart +141 -0
  114. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +0 -1
  115. package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +42 -38
  116. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +17 -2
  117. package/templates/firebase/lib/features/subscriptions/api/entities/subscription_entity.dart +3 -0
  118. package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +12 -11
  119. package/templates/firebase/lib/features/subscriptions/repositories/subscription_repository.dart +25 -2
  120. package/templates/firebase/lib/features/subscriptions/ui/component/active_premium_content.dart +319 -143
  121. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +1 -5
  122. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -5
  123. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +2 -5
  124. package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +1 -4
  125. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +1 -2
  126. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +2 -7
  127. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +2 -4
  128. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +1 -4
  129. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +0 -3
  130. package/templates/firebase/lib/i18n/en.i18n.json +49 -3
  131. package/templates/firebase/lib/i18n/es.i18n.json +49 -3
  132. package/templates/firebase/lib/i18n/pt.i18n.json +49 -3
  133. package/templates/firebase/lib/main.dart +11 -2
  134. package/templates/firebase/lib/router.dart +92 -13
  135. package/templates/firebase/pubspec.yaml +1 -1
  136. package/templates/firebase/test/core/data/api/fake_remote_config_api.dart +14 -0
  137. package/templates/firebase/test/core/states/user_state_notifier_test.dart +47 -3
  138. package/templates/firebase/test/core/web_viewport_scale_test.dart +68 -0
  139. package/templates/firebase/test/features/authentication/data/api/auth_api_fake.dart +15 -0
  140. package/templates/firebase/web/index.html +162 -14
  141. package/templates/firebase/lib/core/guards/user_info_guard.dart +0 -61
@@ -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,
@@ -0,0 +1,89 @@
1
+ import 'package:flutter/gestures.dart';
2
+ import 'package:flutter/services.dart';
3
+ import 'package:flutter/widgets.dart';
4
+
5
+ /// Makes focus rings keyboard-only — the web's `:focus-visible` behaviour.
6
+ ///
7
+ /// Flutter's default [FocusHighlightStrategy.automatic] only hides focus rings
8
+ /// for *touch*; on desktop a mouse click keeps them in "traditional" mode, so a
9
+ /// ring raised by Tab navigation never clears once the user grabs the mouse.
10
+ /// This wraps the app and flips the strategy by input type:
11
+ /// - a navigation key (Tab / arrows / Home / End / Page Up·Down) shows rings;
12
+ /// - any pointer press hides them, and — when the user was mid keyboard
13
+ /// navigation — also drops focus, so the next Tab restarts from the top of
14
+ /// the traversal (skip link → sidebar → content) instead of resuming
15
+ /// wherever the ring happened to be.
16
+ ///
17
+ /// Plain typing does NOT turn rings on (only navigation keys do), so editing a
18
+ /// text field with the mouse stays ring-free. Uses a global pointer route so it
19
+ /// catches clicks anywhere, including empty areas with no hit target.
20
+ class FocusVisibility extends StatefulWidget {
21
+ const FocusVisibility({super.key, required this.child});
22
+
23
+ final Widget child;
24
+
25
+ @override
26
+ State<FocusVisibility> createState() => _FocusVisibilityState();
27
+ }
28
+
29
+ class _FocusVisibilityState extends State<FocusVisibility> {
30
+ static final Set<LogicalKeyboardKey> _navigationKeys = <LogicalKeyboardKey>{
31
+ LogicalKeyboardKey.tab,
32
+ LogicalKeyboardKey.arrowUp,
33
+ LogicalKeyboardKey.arrowDown,
34
+ LogicalKeyboardKey.arrowLeft,
35
+ LogicalKeyboardKey.arrowRight,
36
+ LogicalKeyboardKey.home,
37
+ LogicalKeyboardKey.end,
38
+ LogicalKeyboardKey.pageUp,
39
+ LogicalKeyboardKey.pageDown,
40
+ };
41
+
42
+ @override
43
+ void initState() {
44
+ super.initState();
45
+ // Start ring-free; the first navigation key turns them on.
46
+ FocusManager.instance.highlightStrategy =
47
+ FocusHighlightStrategy.alwaysTouch;
48
+ HardwareKeyboard.instance.addHandler(_handleKey);
49
+ GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointer);
50
+ }
51
+
52
+ @override
53
+ void dispose() {
54
+ GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointer);
55
+ HardwareKeyboard.instance.removeHandler(_handleKey);
56
+ super.dispose();
57
+ }
58
+
59
+ bool _handleKey(KeyEvent event) {
60
+ if (event is KeyDownEvent && _navigationKeys.contains(event.logicalKey)) {
61
+ _setStrategy(FocusHighlightStrategy.alwaysTraditional);
62
+ }
63
+ return false; // observe only — never consume the event
64
+ }
65
+
66
+ void _handlePointer(PointerEvent event) {
67
+ if (event is! PointerDownEvent) {
68
+ return;
69
+ }
70
+ final FocusManager fm = FocusManager.instance;
71
+ final bool wasKeyboardNav =
72
+ fm.highlightMode == FocusHighlightMode.traditional;
73
+ _setStrategy(FocusHighlightStrategy.alwaysTouch);
74
+ // Only reset focus when leaving keyboard navigation; a normal click (e.g.
75
+ // into a text field) then keeps the focus it's about to request.
76
+ if (wasKeyboardNav) {
77
+ fm.primaryFocus?.unfocus();
78
+ }
79
+ }
80
+
81
+ void _setStrategy(FocusHighlightStrategy strategy) {
82
+ if (FocusManager.instance.highlightStrategy != strategy) {
83
+ FocusManager.instance.highlightStrategy = strategy;
84
+ }
85
+ }
86
+
87
+ @override
88
+ Widget build(BuildContext context) => widget.child;
89
+ }
@@ -142,7 +142,6 @@ class _DetailPlaceholder extends StatelessWidget {
142
142
  t.home.cards.assistant_title,
143
143
  textAlign: TextAlign.center,
144
144
  style: context.textTheme.titleMedium?.copyWith(
145
- fontWeight: FontWeight.w600,
146
145
  color: context.colors.onBackground,
147
146
  ),
148
147
  ),
@@ -150,7 +149,7 @@ class _DetailPlaceholder extends StatelessWidget {
150
149
  Text(
151
150
  t.ai_chat.no_conversation_selected,
152
151
  textAlign: TextAlign.center,
153
- style: context.textTheme.bodyLarge?.copyWith(
152
+ style: context.textTheme.bodyMedium?.copyWith(
154
153
  color: context.colors.muted,
155
154
  height: 1.4,
156
155
  ),
@@ -186,7 +186,6 @@ class _EmptyState extends StatelessWidget {
186
186
  t.home.cards.assistant_title,
187
187
  textAlign: TextAlign.center,
188
188
  style: context.textTheme.titleMedium?.copyWith(
189
- fontWeight: FontWeight.w600,
190
189
  color: context.colors.onBackground,
191
190
  ),
192
191
  ),
@@ -194,7 +193,7 @@ class _EmptyState extends StatelessWidget {
194
193
  Text(
195
194
  t.ai_chat.empty_state,
196
195
  textAlign: TextAlign.center,
197
- style: context.textTheme.bodyLarge?.copyWith(
196
+ style: context.textTheme.bodyMedium?.copyWith(
198
197
  color: context.colors.muted,
199
198
  height: 1.4,
200
199
  ),
@@ -207,8 +207,7 @@ class _Header extends StatelessWidget {
207
207
  t.ai_chat.title,
208
208
  maxLines: 1,
209
209
  overflow: TextOverflow.ellipsis,
210
- style: context.textTheme.titleLarge?.copyWith(
211
- fontWeight: FontWeight.w700,
210
+ style: context.kasyTextTheme.pageTitle.copyWith(
212
211
  color: context.colors.onBackground,
213
212
  ),
214
213
  ),
@@ -274,7 +273,7 @@ class _EmptyConversations extends StatelessWidget {
274
273
  child: Text(
275
274
  t.ai_chat.conversations_empty,
276
275
  textAlign: TextAlign.center,
277
- style: context.textTheme.bodyLarge?.copyWith(
276
+ style: context.textTheme.bodyMedium?.copyWith(
278
277
  color: context.colors.muted,
279
278
  height: 1.4,
280
279
  ),
@@ -75,8 +75,7 @@ class _AiConversationTileState extends ConsumerState<AiConversationTile> {
75
75
  title,
76
76
  maxLines: 1,
77
77
  overflow: TextOverflow.ellipsis,
78
- style: context.textTheme.bodyMedium?.copyWith(
79
- fontWeight: FontWeight.w600,
78
+ style: context.kasyTextTheme.rowTitle.copyWith(
80
79
  color: titleColor,
81
80
  ),
82
81
  ),
@@ -0,0 +1,12 @@
1
+ /// Whether the app is running inside a *mobile* web browser (phone/tablet).
2
+ ///
3
+ /// Drives the OAuth flow choice on web: mobile browsers handle provider popups
4
+ /// unreliably — the provider opens in a new tab and the popup result is
5
+ /// frequently lost when the browser reclaims the backgrounded opener tab, so the
6
+ /// sign-in future never resolves and the app stays stuck "signing in". On mobile
7
+ /// we use a full-page redirect instead (completed at startup by
8
+ /// `getRedirectResult`); desktop web keeps the smoother popup.
9
+ ///
10
+ /// Non-web stub: always false. The real implementation lives in
11
+ /// `auth_web_support_web.dart` and is selected via conditional import on web.
12
+ bool isMobileWebBrowser() => false;
@@ -0,0 +1,25 @@
1
+ import 'package:web/web.dart' as web;
2
+
3
+ /// Web implementation: detects a mobile browser from the user-agent (iOS, Android
4
+ /// and other common phone/tablet agents). iPadOS 13+ reports a desktop Safari
5
+ /// user-agent, so we also treat a touch-capable "Macintosh" as mobile.
6
+ ///
7
+ /// A false positive only costs the (also-working) redirect flow instead of the
8
+ /// popup, so the heuristic deliberately errs toward catching mobiles.
9
+ bool isMobileWebBrowser() {
10
+ final ua = web.window.navigator.userAgent.toLowerCase();
11
+ const needles = [
12
+ 'android',
13
+ 'iphone',
14
+ 'ipad',
15
+ 'ipod',
16
+ 'mobile',
17
+ 'windows phone',
18
+ 'blackberry',
19
+ 'opera mini',
20
+ 'iemobile',
21
+ ];
22
+ if (needles.any(ua.contains)) return true;
23
+ // iPadOS 13+ masquerades as desktop Safari but still exposes touch points.
24
+ return ua.contains('macintosh') && web.window.navigator.maxTouchPoints > 1;
25
+ }