kasy-cli 1.31.13 → 1.32.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 (101) hide show
  1. package/lib/commands/new.js +15 -1
  2. package/lib/scaffold/CHANGELOG.json +9 -0
  3. package/lib/scaffold/backends/api/patch/README.md +87 -2
  4. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +34 -0
  5. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  6. package/lib/scaffold/backends/firebase/setup-from-scratch.js +22 -0
  7. package/lib/scaffold/backends/supabase/deploy.js +5 -0
  8. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/README.md +26 -22
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -11
  10. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +3 -1
  11. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +60 -3
  12. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +69 -17
  13. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +6 -0
  15. package/lib/scaffold/generate.js +1 -1
  16. package/lib/scaffold/shared/generator-utils.js +22 -3
  17. package/lib/utils/i18n/messages-en.js +2 -0
  18. package/lib/utils/i18n/messages-es.js +2 -0
  19. package/lib/utils/i18n/messages-pt.js +2 -0
  20. package/package.json +2 -2
  21. package/templates/firebase/docs/auth-setup.en.md +7 -1
  22. package/templates/firebase/docs/auth-setup.es.md +7 -1
  23. package/templates/firebase/docs/auth-setup.pt.md +7 -1
  24. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +79 -5
  25. package/templates/firebase/lib/components/kasy_accordion.dart +2 -2
  26. package/templates/firebase/lib/components/kasy_alert.dart +1 -1
  27. package/templates/firebase/lib/components/kasy_app_bar.dart +3 -3
  28. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +1 -1
  29. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  30. package/templates/firebase/lib/components/kasy_chip.dart +1 -1
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +26 -21
  32. package/templates/firebase/lib/components/kasy_dialog.dart +2 -2
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +62 -11
  34. package/templates/firebase/lib/components/kasy_tabs.dart +2 -2
  35. package/templates/firebase/lib/components/kasy_text_area.dart +37 -5
  36. package/templates/firebase/lib/components/kasy_text_field.dart +77 -16
  37. package/templates/firebase/lib/components/kasy_toast.dart +1 -1
  38. package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
  39. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +6 -0
  40. package/templates/firebase/lib/core/bottom_menu/notification_bottom_item.dart +16 -37
  41. package/templates/firebase/lib/core/config/features.dart +13 -0
  42. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +21 -0
  43. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +1 -1
  44. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +1 -1
  45. package/templates/firebase/lib/core/theme/icon_sizes.dart +47 -0
  46. package/templates/firebase/lib/core/theme/shadows.dart +13 -0
  47. package/templates/firebase/lib/core/theme/texts.dart +32 -0
  48. package/templates/firebase/lib/core/theme/theme.dart +2 -0
  49. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -0
  50. package/templates/firebase/lib/core/web_viewport_scale.dart +23 -4
  51. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +1 -1
  52. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
  53. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +1 -1
  54. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -1
  55. package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +3 -1
  56. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +36 -14
  57. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +27 -11
  58. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +1 -1
  59. package/templates/firebase/lib/features/feedbacks/ui/feedback_page.dart +1 -1
  60. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -1
  61. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -1
  62. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +22 -3
  63. package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
  64. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +2 -2
  65. package/templates/firebase/lib/features/notifications/providers/unread_notifications_count_provider.dart +17 -0
  66. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
  67. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +35 -38
  68. package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +1 -1
  69. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +1 -1
  70. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
  71. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +1 -1
  72. package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
  73. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
  74. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +13 -6
  75. package/templates/firebase/lib/features/settings/ui/components/edit_name_sheet.dart +115 -0
  76. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +2 -2
  77. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +1 -1
  78. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +13 -5
  79. package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  80. package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +7 -1
  81. package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +11 -3
  82. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -1
  83. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +1 -1
  84. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +2 -2
  85. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +1 -1
  86. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +1 -1
  87. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +3 -3
  88. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +1 -1
  89. package/templates/firebase/lib/i18n/en.i18n.json +10 -1
  90. package/templates/firebase/lib/i18n/es.i18n.json +10 -1
  91. package/templates/firebase/lib/i18n/pt.i18n.json +10 -1
  92. package/templates/firebase/pubspec.yaml +0 -1
  93. package/templates/firebase/web/stripe_success.html +64 -26
  94. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  95. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  96. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  97. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  98. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  99. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  100. package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
  101. package/templates/firebase/login-redesign-preview.png +0 -0
@@ -1,48 +1,27 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
- import 'package:kasy_kit/core/states/user_state_notifier.dart';
4
3
  import 'package:kasy_kit/core/theme/theme.dart';
5
- import 'package:kasy_kit/features/notifications/repositories/notifications_repository.dart';
4
+ import 'package:kasy_kit/features/notifications/providers/unread_notifications_count_provider.dart';
6
5
 
7
- class BottomItemNotification extends ConsumerStatefulWidget {
6
+ /// Bottom-bar notifications icon with an unread-count badge.
7
+ ///
8
+ /// The badge is purely an unread indicator (works on web too) — it is not tied
9
+ /// to push notifications, which are native-only. The count comes from the shared
10
+ /// [unreadNotificationsCountProvider], which re-subscribes per signed-in user.
11
+ class BottomItemNotification extends ConsumerWidget {
8
12
  const BottomItemNotification({super.key});
9
13
 
10
14
  @override
11
- ConsumerState<ConsumerStatefulWidget> createState() =>
12
- _BottomItemNotificationState();
13
- }
14
-
15
- class _BottomItemNotificationState
16
- extends ConsumerState<BottomItemNotification> {
17
- Stream<int>? _count$;
18
-
19
- @override
20
- Widget build(BuildContext context) {
21
- final String? userId =
22
- ref.watch(userStateNotifierProvider).user.idOrNull;
15
+ Widget build(BuildContext context, WidgetRef ref) {
23
16
  const icon = Icon(KasyIcons.notification);
24
-
25
- if (userId == null) {
26
- return icon;
27
- }
28
-
29
- _count$ ??= ref
30
- .read(notificationRepositoryProvider)
31
- .listenToUnreadNotificationsCount(userId);
32
-
33
- return StreamBuilder<int>(
34
- key: const ValueKey('notification-count'),
35
- stream: _count$,
36
- builder: (context, snapshot) {
37
- final count = snapshot.data ?? 0;
38
- if (count == 0) return icon;
39
- return Badge(
40
- backgroundColor: context.colors.error,
41
- textColor: context.colors.onPrimary,
42
- label: Text(count > 99 ? '99+' : '$count'),
43
- child: icon,
44
- );
45
- },
17
+ final int count =
18
+ ref.watch(unreadNotificationsCountProvider).value ?? 0;
19
+ if (count == 0) return icon;
20
+ return Badge(
21
+ backgroundColor: context.colors.error,
22
+ textColor: context.colors.onError,
23
+ label: Text(count > 99 ? '99+' : '$count'),
24
+ child: icon,
46
25
  );
47
26
  }
48
27
  }
@@ -6,6 +6,12 @@ const bool withAiChat = true;
6
6
  const bool withFeedback = true;
7
7
  const bool withRevenuecat = true;
8
8
  const bool withLocalReminders = true;
9
+ /// When true, the Apple sign-in button is shown on WEB. Apple-on-web needs a paid
10
+ /// Apple Service ID + manual setup (see docs/auth-setup): Firebase can do it
11
+ /// (signInWithPopup), so this ships `true`; Supabase/API can't (no web secret), so
12
+ /// the CLI generates them as `false`. Apple shows on iOS/macOS regardless; it is
13
+ /// hidden on Android (also needs a paid Service ID, and the native flow throws).
14
+ const bool withAppleWebSignin = true;
9
15
  /// When true, the app includes web support:
10
16
  /// - anonymous sign-up is disabled on web (user is redirected to /signin)
11
17
  /// - onboarding is skipped on web
@@ -14,3 +20,10 @@ const bool withWeb = true;
14
20
  /// When true, the Stripe web-subscription module is included (web checkout +
15
21
  /// customer portal). Independent from RevenueCat (which stays mobile-only).
16
22
  const bool withStripe = true;
23
+ /// When true, Stripe Checkout shows a promo-code / coupon field.
24
+ /// Set to false if you have no promotions strategy yet.
25
+ const bool withStripePromoCodes = true;
26
+ /// When true, the Stripe Customer Portal lets subscribers switch plans
27
+ /// (upgrade / downgrade). Requires at least two recurring prices on the same
28
+ /// product. The portal configuration is created automatically on first use.
29
+ const bool withStripePlanSwitching = true;
@@ -36,6 +36,13 @@ final ValueNotifier<bool> devInspectorEnabledNotifier =
36
36
  final ValueNotifier<bool> devInspectorCopyTriggerNotifier =
37
37
  ValueNotifier<bool>(false);
38
38
 
39
+ /// Set to true to clear the current selection WITHOUT deactivating the
40
+ /// inspector. The Web Device Preview toggle fires this when entering/leaving
41
+ /// the device frame so a stale highlight doesn't linger across the transition.
42
+ /// [DevInspector] clears the selection and resets this to false.
43
+ final ValueNotifier<bool> devInspectorClearSelectionTriggerNotifier =
44
+ ValueNotifier<bool>(false);
45
+
39
46
  /// Runtime active state of the inspector. Mirrors [devInspectorEnabledNotifier]
40
47
  /// — the Web Device Preview pill, the admin toggle and the Esc shortcut all
41
48
  /// flip the persisted notifier, and this one follows.
@@ -127,6 +134,8 @@ class _DevInspectorState extends State<DevInspector>
127
134
  devInspectorActiveNotifier.addListener(_handleActiveChanged);
128
135
  devInspectorEnabledNotifier.addListener(_handleEnabledChanged);
129
136
  devInspectorCopyTriggerNotifier.addListener(_onCopyTriggered);
137
+ devInspectorClearSelectionTriggerNotifier
138
+ .addListener(_onClearSelectionTriggered);
130
139
  HardwareKeyboard.instance.addHandler(_handleKeyEvent);
131
140
  unawaited(_bootstrapEnabledPreference());
132
141
  }
@@ -139,6 +148,8 @@ class _DevInspectorState extends State<DevInspector>
139
148
  HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
140
149
  devInspectorEnabledNotifier.removeListener(_handleEnabledChanged);
141
150
  devInspectorCopyTriggerNotifier.removeListener(_onCopyTriggered);
151
+ devInspectorClearSelectionTriggerNotifier
152
+ .removeListener(_onClearSelectionTriggered);
142
153
  devInspectorActiveNotifier.removeListener(_handleActiveChanged);
143
154
  if (devInspectorActiveNotifier.value) {
144
155
  devInspectorActiveNotifier.value = false;
@@ -312,6 +323,16 @@ class _DevInspectorState extends State<DevInspector>
312
323
  unawaited(_copySelection());
313
324
  }
314
325
 
326
+ /// Drops the current selection while keeping the inspector active, so a stale
327
+ /// highlight doesn't carry over when the Web Device Preview is toggled.
328
+ void _onClearSelectionTriggered() {
329
+ if (!devInspectorClearSelectionTriggerNotifier.value) return;
330
+ devInspectorClearSelectionTriggerNotifier.value = false;
331
+ if (!mounted) return;
332
+ if (_selectedInfo == null && _selectedRender == null) return;
333
+ setState(_clearSelection);
334
+ }
335
+
315
336
  void _handleActiveChanged() {
316
337
  final bool active = devInspectorActiveNotifier.value;
317
338
  if (_active == active) return;
@@ -127,7 +127,7 @@ class RateBannerWidget extends StatelessWidget {
127
127
  color: cs.primary.withValues(alpha: 0.12),
128
128
  shape: BoxShape.circle,
129
129
  ),
130
- child: Icon(KasyIcons.star, color: cs.primary, size: 28),
130
+ child: Icon(KasyIcons.star, color: cs.primary, size: KasyIconSize.xxl),
131
131
  ),
132
132
  const SizedBox(height: KasySpacing.md),
133
133
  Text(
@@ -165,7 +165,7 @@ class CloseIcon extends StatelessWidget {
165
165
  child: Icon(
166
166
  KasyIcons.close,
167
167
  color: context.colors.onBackground,
168
- size: 21,
168
+ size: KasyIconSize.lg,
169
169
  ),
170
170
  ),
171
171
  ),
@@ -0,0 +1,47 @@
1
+ /// Kasy Design System — Icon Size Tokens
2
+ ///
3
+ /// Single source of truth for every icon dimension in the app. Screens and
4
+ /// components must read from here instead of hardcoding `size: 20` etc., so the
5
+ /// whole product can be re-scaled from one place.
6
+ ///
7
+ /// Usage: `Icon(KasyIcons.person, size: KasyIconSize.rowLeading)`
8
+ ///
9
+ /// Scale reference:
10
+ /// xxs → 12 micro glyphs inside small badges
11
+ /// xs → 14 tiny, dense inline glyphs
12
+ /// sm → 16 secondary / trailing (chevrons, value adornments)
13
+ /// md → 18 inline-with-text (chips, accordions, alerts)
14
+ /// lg → 20 default — list-row leading icons, chrome actions
15
+ /// xl → 24 prominent / large chrome
16
+ /// xxl → 28 feature highlights
17
+ /// display → 36 illustrative glyphs (empty states, brand badges)
18
+ /// hero → 72 hero glyph at the top of a focused screen (auth, onboarding)
19
+ class KasyIconSize {
20
+ KasyIconSize._();
21
+
22
+ static const double xxs = 12;
23
+ static const double xs = 14;
24
+ static const double sm = 16;
25
+ static const double md = 18;
26
+ static const double lg = 20;
27
+ static const double xl = 24;
28
+ static const double xxl = 28;
29
+ static const double display = 36;
30
+ static const double hero = 72;
31
+
32
+ // ── Semantic aliases ─────────────────────────────────────────────────────
33
+ // Prefer these in screens so intent is explicit and a single edit re-scales
34
+ // every row/chrome at once.
35
+
36
+ /// Leading icon in a list / settings row (the standard list glyph).
37
+ static const double rowLeading = lg; // 20
38
+
39
+ /// Trailing affordance in a row — chevron, small value adornment.
40
+ static const double rowTrailing = sm; // 16
41
+
42
+ /// Action icons in the top/bottom chrome (app bar orbs, nav).
43
+ static const double chrome = lg; // 20
44
+
45
+ /// Glyph sitting inline next to body text (chips, inline status).
46
+ static const double inline = md; // 18
47
+ }
@@ -38,6 +38,19 @@ class KasyShadows {
38
38
  );
39
39
  }
40
40
 
41
+ /// Resting border for the FLAT [KasyTextField] / [KasyTextArea] variant.
42
+ ///
43
+ /// Flat fields share their container's fill (no fill contrast) and cast no
44
+ /// shadow, so the border alone separates them from the surface behind. It is
45
+ /// derived from `onSurface` (not the fixed-hue `outline` token) so it reads
46
+ /// clearly in BOTH modes: a soft dark hairline on light, a soft light
47
+ /// hairline on dark — where the regular hairline would vanish.
48
+ static Color inputFieldFlatBorder(BuildContext context) {
49
+ return context.colors.onSurface.withValues(
50
+ alpha: context.isDark ? 0.16 : 0.12,
51
+ );
52
+ }
53
+
41
54
  /// Standard shadow for input surfaces ([KasyTextField] and [KasyTextArea]).
42
55
  ///
43
56
  /// Slightly tighter and softer than [component] — native only (callers skip on web).
@@ -54,6 +54,38 @@ class KasyTextTheme extends ThemeExtension<KasyTextTheme> {
54
54
  static TextStyle get buttonBase => _inter(FontWeight.w500, 16, 24);
55
55
  static TextStyle get buttonSm => _inter(FontWeight.w500, 14, 20);
56
56
 
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;
88
+
57
89
  // -----------------------------------------------------------------------
58
90
  // Display — large hero text (extends the HeroUI scale upward in Inter)
59
91
  // -----------------------------------------------------------------------
@@ -9,11 +9,13 @@
9
9
  /// KasyTextTheme → context.textTheme.bodyMedium / .titleLarge / etc.
10
10
  /// KasySpacing → KasySpacing.md (16) / .lg (24) / etc.
11
11
  /// KasyRadius → KasyRadius.smBorderRadius / .lgBorderRadius / etc.
12
+ /// KasyIconSize → KasyIconSize.rowLeading (20) / .rowTrailing (16) / etc.
12
13
  library;
13
14
 
14
15
  export '../icons/kasy_icons.dart';
15
16
  export 'colors.dart';
16
17
  export 'extensions/theme_extension.dart';
18
+ export 'icon_sizes.dart';
17
19
  export 'providers/theme_provider.dart';
18
20
  export 'radius.dart';
19
21
  export 'shadows.dart';
@@ -189,6 +189,9 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
189
189
  // the DevInspector's in-app status pill while the preview is on.
190
190
  devInspectorSuppressStatusPillNotifier.value =
191
191
  webDevicePreviewEnabledNotifier.value;
192
+ // Entering or leaving the device frame: drop any lingering inspector
193
+ // selection so an old highlight doesn't carry across the transition.
194
+ devInspectorClearSelectionTriggerNotifier.value = true;
192
195
  if (webDevicePreviewEnabledNotifier.value) {
193
196
  _controlsTimer?.cancel();
194
197
  _controlsTimer = Timer(const Duration(milliseconds: 800), () {
@@ -1,14 +1,25 @@
1
1
  import 'package:flutter/foundation.dart' show kIsWeb;
2
2
  import 'package:flutter/widgets.dart';
3
3
 
4
- /// Global render scale applied to the app on web.
4
+ /// Maximum render scale applied to the app on web (used on wide viewports).
5
5
  ///
6
6
  /// Flutter web tends to render ~10% larger than equivalent HTML apps at the
7
7
  /// browser's 100% zoom, so the whole UI feels oversized on desktop. `0.95`
8
8
  /// 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.
9
+ /// like) without the user having to touch the browser zoom. On narrower
10
+ /// viewports the effective scale is reduced further (see [kWebViewportScaleTargetWidth]).
10
11
  const double kWebViewportScale = 0.95;
11
12
 
13
+ /// Design target width (logical px) the desktop shell is laid out against.
14
+ ///
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.
21
+ const double kWebViewportScaleTargetWidth = 1280;
22
+
12
23
  /// Minimum real viewport width (logical px) at which the web scale kicks in.
13
24
  ///
14
25
  /// The "oversized" problem only shows up on tablet/desktop layouts. On mobile
@@ -44,9 +55,17 @@ class WebViewportScale extends StatelessWidget {
44
55
  // Mobile web (narrow browser) renders at its natural size, just like the
45
56
  // native build. The scale only applies from the tablet breakpoint up.
46
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);
65
+ if (effectiveScale == 1.0) return child;
47
66
  final Size logicalSize = Size(
48
- mq.size.width / scale,
49
- mq.size.height / scale,
67
+ mq.size.width / effectiveScale,
68
+ mq.size.height / effectiveScale,
50
69
  );
51
70
  return MediaQuery(
52
71
  data: mq.copyWith(size: logicalSize),
@@ -81,7 +81,7 @@ class _UpdateBottomSheet extends StatelessWidget {
81
81
  child: Icon(
82
82
  KasyIcons.star,
83
83
  color: context.colors.primary,
84
- size: 24,
84
+ size: KasyIconSize.xl,
85
85
  ),
86
86
  ),
87
87
  const SizedBox(width: KasySpacing.md),
@@ -133,7 +133,7 @@ class _AiConversationTileState extends ConsumerState<AiConversationTile> {
133
133
  child: InkResponse(
134
134
  onTap: widget.onDelete,
135
135
  radius: 18,
136
- child: Icon(KasyIcons.trash, size: 16, color: context.colors.error),
136
+ child: Icon(KasyIcons.trash, size: KasyIconSize.sm, color: context.colors.error),
137
137
  ),
138
138
  );
139
139
  }
@@ -32,7 +32,7 @@ class _OtpVerificationComponentState
32
32
  crossAxisAlignment: CrossAxisAlignment.stretch,
33
33
  children: [
34
34
  const SizedBox(height: KasySpacing.lg),
35
- Icon(KasyIcons.sms, size: 72, color: context.colors.primary),
35
+ Icon(KasyIcons.sms, size: KasyIconSize.hero, color: context.colors.primary),
36
36
  const SizedBox(height: KasySpacing.lg),
37
37
  Text(
38
38
  t.phone_auth.verification_code,
@@ -34,7 +34,7 @@ class _PhoneInputComponentState extends ConsumerState<PhoneInputComponent> {
34
34
  crossAxisAlignment: CrossAxisAlignment.stretch,
35
35
  children: [
36
36
  const SizedBox(height: KasySpacing.lg),
37
- Icon(KasyIcons.phoneAndroid, size: 72, color: context.colors.primary),
37
+ Icon(KasyIcons.phoneAndroid, size: KasyIconSize.hero, color: context.colors.primary),
38
38
  const SizedBox(height: KasySpacing.lg),
39
39
  Text(
40
40
  t.phone_auth.subtitle_input,
@@ -71,6 +71,7 @@ class _PhoneInputComponentState extends ConsumerState<PhoneInputComponent> {
71
71
  ),
72
72
  ),
73
73
  KasyTextField(
74
+ variant: KasyTextFieldVariant.flat,
74
75
  controller: _phoneController,
75
76
  keyboardType: TextInputType.phone,
76
77
  label: t.phone_auth.phone_label,
@@ -53,6 +53,7 @@ class RecoverPasswordPage extends ConsumerWidget {
53
53
  subtitle: t.auth.recover.subtitle,
54
54
  children: [
55
55
  KasyTextField(
56
+ variant: KasyTextFieldVariant.flat,
56
57
  key: const Key('email_input'),
57
58
  label: t.auth.recover.email_label,
58
59
  contentType: KasyTextFieldContentType.email,
@@ -124,7 +125,8 @@ class _BackToSigninPrompt extends StatelessWidget {
124
125
  context.go('/signin');
125
126
  }
126
127
  },
127
- focusable: true,
128
+ // Secondary link: kept out of Tab traversal (focusable defaults to
129
+ // false) so keyboard/next flows email → submit, not this link.
128
130
  child: Text(
129
131
  t.auth.recover.signin_link,
130
132
  style: context.textTheme.bodyMedium?.copyWith(
@@ -1,9 +1,11 @@
1
1
  import 'dart:ui';
2
2
 
3
+ import 'package:flutter/foundation.dart';
3
4
  import 'package:flutter/material.dart';
4
5
  import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6
  import 'package:go_router/go_router.dart';
6
7
  import 'package:kasy_kit/components/components.dart';
8
+ import 'package:kasy_kit/core/config/features.dart';
7
9
  import 'package:kasy_kit/core/data/models/user.dart';
8
10
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
9
11
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
@@ -64,6 +66,7 @@ class SigninPage extends ConsumerWidget {
64
66
  subtitle: t.auth.signin.subtitle,
65
67
  children: [
66
68
  KasyTextField(
69
+ variant: KasyTextFieldVariant.flat,
67
70
  key: const Key('email_input'),
68
71
  onChanged: (value) => ref
69
72
  .read(signinStateProvider.notifier)
@@ -83,6 +86,7 @@ class SigninPage extends ConsumerWidget {
83
86
  ),
84
87
  const SizedBox(height: _authFieldSpacing),
85
88
  KasyTextField(
89
+ variant: KasyTextFieldVariant.flat,
86
90
  key: const Key('password_input'),
87
91
  onChanged: (newValue) => ref
88
92
  .read(signinStateProvider.notifier)
@@ -98,10 +102,15 @@ class SigninPage extends ConsumerWidget {
98
102
  FocusScope.of(context).unfocus();
99
103
  ref.read(signinStateProvider.notifier).signin();
100
104
  },
101
- labelTrailing: KasyFocusRing(
102
- onActivate: () => context.push('/recover_password'),
103
- borderRadius: BorderRadius.circular(KasyRadius.sm),
105
+ // Secondary link: intentionally NOT a Tab stop, so
106
+ // Tab/next flows email → password → submit → social
107
+ // buttons. Still tappable by mouse/touch and announced
108
+ // to screen readers as a button.
109
+ labelTrailing: Semantics(
110
+ button: true,
111
+ label: t.auth.signin.forgot_password,
104
112
  child: GestureDetector(
113
+ behavior: HitTestBehavior.opaque,
105
114
  onTap: () => context.push('/recover_password'),
106
115
  child: Text(
107
116
  t.auth.signin.forgot_password,
@@ -195,7 +204,9 @@ class _SignupPrompt extends StatelessWidget {
195
204
  KasyPressableDepth(
196
205
  semanticLabel: t.auth.signin.signup_link,
197
206
  onPressed: () => context.pushReplacement('/signup'),
198
- focusable: true,
207
+ // Secondary link: kept out of Tab traversal (focusable defaults to
208
+ // false) so keyboard/next jumps straight to the social sign-in
209
+ // buttons (the most-used path).
199
210
  child: Text(
200
211
  t.auth.signin.signup_link,
201
212
  style: context.textTheme.bodyMedium?.copyWith(
@@ -218,6 +229,14 @@ class _SocialSigninRow extends ConsumerWidget {
218
229
  Widget build(BuildContext context, WidgetRef ref) {
219
230
  final state = ref.watch(signinStateProvider);
220
231
  final isSending = state is SigninStateSending;
232
+ // Apple sign-in is reliable only on Apple-native platforms (iOS/macOS). On web
233
+ // it needs a paid Apple Service ID + manual console setup (see auth docs), gated
234
+ // by withAppleWebSignin (off by default). On Android it needs the same Service ID
235
+ // and the Supabase/API native flow throws, so Apple is hidden there.
236
+ final bool showApple = kIsWeb
237
+ ? withAppleWebSignin
238
+ : (defaultTargetPlatform == TargetPlatform.iOS ||
239
+ defaultTargetPlatform == TargetPlatform.macOS);
221
240
  return Row(
222
241
  children: [
223
242
  Expanded(
@@ -230,17 +249,20 @@ class _SocialSigninRow extends ConsumerWidget {
230
249
  ref.read(signinStateProvider.notifier).signinWithGoogle(),
231
250
  ),
232
251
  ),
233
- const SizedBox(width: KasySpacing.sm),
234
- Expanded(
235
- child: _SocialSigninTile(
236
- label: t.auth.signin.apple,
237
- icon: Image.asset('assets/icons/apple.png', width: 20, height: 20),
238
- onPressed: isSending
239
- ? null
240
- : () =>
241
- ref.read(signinStateProvider.notifier).signinWithApple(),
252
+ if (showApple) ...[
253
+ const SizedBox(width: KasySpacing.sm),
254
+ Expanded(
255
+ child: _SocialSigninTile(
256
+ label: t.auth.signin.apple,
257
+ icon: Image.asset('assets/icons/apple.png', width: 20, height: 20),
258
+ onPressed: isSending
259
+ ? null
260
+ : () => ref
261
+ .read(signinStateProvider.notifier)
262
+ .signinWithApple(),
263
+ ),
242
264
  ),
243
- ),
265
+ ],
244
266
  const SizedBox(width: KasySpacing.sm),
245
267
  Expanded(
246
268
  child: _SocialSigninTile(
@@ -1,9 +1,11 @@
1
1
  import 'dart:ui';
2
2
 
3
+ import 'package:flutter/foundation.dart';
3
4
  import 'package:flutter/material.dart';
4
5
  import 'package:flutter_riverpod/flutter_riverpod.dart';
5
6
  import 'package:go_router/go_router.dart';
6
7
  import 'package:kasy_kit/components/components.dart';
8
+ import 'package:kasy_kit/core/config/features.dart';
7
9
  import 'package:kasy_kit/core/data/models/user.dart';
8
10
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
9
11
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
@@ -60,6 +62,7 @@ class SignupPage extends ConsumerWidget {
60
62
  subtitle: t.auth.signup.subtitle,
61
63
  children: [
62
64
  KasyTextField(
65
+ variant: KasyTextFieldVariant.flat,
63
66
  key: const Key('email_input'),
64
67
  onChanged: (value) => ref
65
68
  .read(signupStateProvider.notifier)
@@ -79,6 +82,7 @@ class SignupPage extends ConsumerWidget {
79
82
  ),
80
83
  const SizedBox(height: _authFieldSpacing),
81
84
  KasyTextField(
85
+ variant: KasyTextFieldVariant.flat,
82
86
  key: const Key('password_input'),
83
87
  onChanged: (newValue) => ref
84
88
  .read(signupStateProvider.notifier)
@@ -180,7 +184,8 @@ class _SigninPrompt extends StatelessWidget {
180
184
  KasyPressableDepth(
181
185
  semanticLabel: t.auth.signup.signin_link,
182
186
  onPressed: () => context.pushReplacement('/signin'),
183
- focusable: true,
187
+ // Secondary link: kept out of Tab traversal (focusable defaults to
188
+ // false) so keyboard/next jumps straight to the social buttons.
184
189
  child: Text(
185
190
  t.auth.signup.signin_link,
186
191
  style: context.textTheme.bodyMedium?.copyWith(
@@ -203,6 +208,14 @@ class _SocialSignupRow extends ConsumerWidget {
203
208
  Widget build(BuildContext context, WidgetRef ref) {
204
209
  final state = ref.watch(signinStateProvider);
205
210
  final isSending = state is SigninStateSending;
211
+ // Apple sign-in is reliable only on Apple-native platforms (iOS/macOS). On web
212
+ // it needs a paid Apple Service ID + manual console setup (see auth docs), gated
213
+ // by withAppleWebSignin (off by default). On Android it needs the same Service ID
214
+ // and the Supabase/API native flow throws, so Apple is hidden there.
215
+ final bool showApple = kIsWeb
216
+ ? withAppleWebSignin
217
+ : (defaultTargetPlatform == TargetPlatform.iOS ||
218
+ defaultTargetPlatform == TargetPlatform.macOS);
206
219
  return Row(
207
220
  children: [
208
221
  Expanded(
@@ -215,17 +228,20 @@ class _SocialSignupRow extends ConsumerWidget {
215
228
  ref.read(signinStateProvider.notifier).signinWithGoogle(),
216
229
  ),
217
230
  ),
218
- const SizedBox(width: KasySpacing.sm),
219
- Expanded(
220
- child: _SocialSignupTile(
221
- label: t.auth.signin.apple,
222
- icon: Image.asset('assets/icons/apple.png', width: 20, height: 20),
223
- onPressed: isSending
224
- ? null
225
- : () =>
226
- ref.read(signinStateProvider.notifier).signinWithApple(),
231
+ if (showApple) ...[
232
+ const SizedBox(width: KasySpacing.sm),
233
+ Expanded(
234
+ child: _SocialSignupTile(
235
+ label: t.auth.signin.apple,
236
+ icon: Image.asset('assets/icons/apple.png', width: 20, height: 20),
237
+ onPressed: isSending
238
+ ? null
239
+ : () => ref
240
+ .read(signinStateProvider.notifier)
241
+ .signinWithApple(),
242
+ ),
227
243
  ),
228
- ),
244
+ ],
229
245
  const SizedBox(width: KasySpacing.sm),
230
246
  Expanded(
231
247
  child: _SocialSignupTile(
@@ -28,7 +28,7 @@ class RecoverPasswordSent extends StatelessWidget {
28
28
  KasyBrandBadge(
29
29
  icon: KasyIcons.checkCircle,
30
30
  size: 72,
31
- glyphSize: 36,
31
+ glyphSize: KasyIconSize.display,
32
32
  gradient: LinearGradient(
33
33
  begin: Alignment.topLeft,
34
34
  end: Alignment.bottomRight,
@@ -167,7 +167,7 @@ class _AddChipButton extends StatelessWidget {
167
167
  child: Row(
168
168
  mainAxisSize: MainAxisSize.min,
169
169
  children: [
170
- Icon(KasyIcons.add, size: 14, color: context.colors.primary),
170
+ Icon(KasyIcons.add, size: KasyIconSize.xs, color: context.colors.primary),
171
171
  const SizedBox(width: KasySpacing.xs),
172
172
  Text(
173
173
  label,
@@ -56,7 +56,7 @@ class AddFeatureButton extends StatelessWidget {
56
56
  const SizedBox(width: KasySpacing.smd),
57
57
  Icon(
58
58
  KasyIcons.northEast,
59
- size: 16,
59
+ size: KasyIconSize.rowTrailing,
60
60
  color: context.colors.onPrimary.withValues(alpha: 0.55),
61
61
  ),
62
62
  ],
@@ -220,7 +220,7 @@ class _VoteCardState extends State<VoteCard>
220
220
  children: [
221
221
  Icon(
222
222
  KasyIcons.voteUp,
223
- size: 22,
223
+ size: KasyIconSize.lg,
224
224
  color: widget.textColor,
225
225
  ),
226
226
  ClipRect(