kasy-cli 1.31.14 → 1.34.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 (127) hide show
  1. package/bin/kasy.js +42 -0
  2. package/lib/commands/apple-web.js +222 -0
  3. package/lib/commands/configure.js +3 -91
  4. package/lib/commands/doctor.js +20 -0
  5. package/lib/commands/facebook.js +189 -0
  6. package/lib/commands/new.js +65 -3
  7. package/lib/scaffold/CHANGELOG.json +27 -0
  8. package/lib/scaffold/backends/api/patch/README.md +87 -2
  9. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +34 -0
  10. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  11. package/lib/scaffold/backends/firebase/setup-from-scratch.js +186 -0
  12. package/lib/scaffold/backends/supabase/deploy.js +92 -0
  13. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/README.md +26 -22
  14. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -11
  15. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +3 -1
  16. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +60 -3
  17. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +22 -0
  18. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  19. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +3 -2
  20. package/lib/scaffold/generate.js +1 -1
  21. package/lib/scaffold/shared/generator-utils.js +34 -3
  22. package/lib/utils/apple-web.js +147 -0
  23. package/lib/utils/facebook.js +162 -0
  24. package/lib/utils/i18n/messages-en.js +64 -0
  25. package/lib/utils/i18n/messages-es.js +64 -0
  26. package/lib/utils/i18n/messages-pt.js +64 -0
  27. package/package.json +2 -2
  28. package/templates/firebase/AGENTS.md +87 -0
  29. package/templates/firebase/CLAUDE.md +16 -0
  30. package/templates/firebase/DESIGN_SYSTEM.md +234 -0
  31. package/templates/firebase/docs/auth-setup.en.md +7 -1
  32. package/templates/firebase/docs/auth-setup.es.md +7 -1
  33. package/templates/firebase/docs/auth-setup.pt.md +7 -1
  34. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +79 -5
  35. package/templates/firebase/lib/components/components.dart +1 -0
  36. package/templates/firebase/lib/components/kasy_accordion.dart +2 -2
  37. package/templates/firebase/lib/components/kasy_alert.dart +1 -1
  38. package/templates/firebase/lib/components/kasy_app_bar.dart +7 -4
  39. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +1 -1
  40. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  41. package/templates/firebase/lib/components/kasy_chip.dart +1 -1
  42. package/templates/firebase/lib/components/kasy_date_picker.dart +26 -21
  43. package/templates/firebase/lib/components/kasy_dialog.dart +2 -2
  44. package/templates/firebase/lib/components/kasy_screen.dart +114 -0
  45. package/templates/firebase/lib/components/kasy_sidebar.dart +2 -2
  46. package/templates/firebase/lib/components/kasy_tabs.dart +2 -2
  47. package/templates/firebase/lib/components/kasy_text_area.dart +37 -5
  48. package/templates/firebase/lib/components/kasy_text_field.dart +77 -16
  49. package/templates/firebase/lib/components/kasy_toast.dart +39 -70
  50. package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
  51. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
  52. package/templates/firebase/lib/core/config/features.dart +18 -0
  53. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +21 -0
  54. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +1 -1
  55. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +46 -124
  56. package/templates/firebase/lib/core/theme/icon_sizes.dart +47 -0
  57. package/templates/firebase/lib/core/theme/shadows.dart +13 -0
  58. package/templates/firebase/lib/core/theme/texts.dart +32 -0
  59. package/templates/firebase/lib/core/theme/theme.dart +2 -0
  60. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -0
  61. package/templates/firebase/lib/core/web_viewport_scale.dart +23 -4
  62. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
  63. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -7
  64. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
  65. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  66. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
  67. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +61 -0
  68. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +1 -1
  69. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -1
  70. package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +3 -1
  71. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +57 -29
  72. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +47 -25
  73. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +1 -1
  74. package/templates/firebase/lib/features/feedbacks/ui/feedback_page.dart +1 -1
  75. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -1
  76. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +2 -3
  77. package/templates/firebase/lib/features/home/home_components_page.dart +7 -1
  78. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +54 -3
  79. package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
  80. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +165 -209
  81. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
  82. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
  83. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
  84. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -6
  85. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
  86. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +104 -156
  87. package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +1 -1
  88. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +1 -1
  89. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
  90. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
  91. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
  92. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +3 -2
  93. package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
  94. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +17 -8
  95. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +4 -4
  96. package/templates/firebase/lib/features/settings/ui/components/edit_name_sheet.dart +115 -0
  97. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +2 -2
  98. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +1 -1
  99. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +13 -5
  100. package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  101. package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +7 -1
  102. package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +11 -3
  103. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -1
  104. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +1 -1
  105. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +2 -2
  106. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +1 -1
  107. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +1 -1
  108. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +3 -3
  109. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +1 -1
  110. package/templates/firebase/lib/i18n/en.i18n.json +13 -4
  111. package/templates/firebase/lib/i18n/es.i18n.json +13 -4
  112. package/templates/firebase/lib/i18n/pt.i18n.json +13 -4
  113. package/templates/firebase/lib/router.dart +2 -0
  114. package/templates/firebase/pubspec.yaml +1 -2
  115. package/templates/firebase/tool/design_check.dart +152 -0
  116. package/templates/firebase/web/stripe_success.html +64 -26
  117. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  118. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  119. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  120. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  121. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  122. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  123. package/templates/firebase/assets/images/review.png +0 -0
  124. package/templates/firebase/assets/images/update.png +0 -0
  125. package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
  126. package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
  127. package/templates/firebase/login-redesign-preview.png +0 -0
@@ -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),
@@ -3,17 +3,19 @@ import 'package:kasy_kit/components/components.dart';
3
3
  import 'package:kasy_kit/core/theme/theme.dart';
4
4
  import 'package:kasy_kit/i18n/translations.g.dart';
5
5
 
6
- /// Shows a modal bottom sheet displaying app update information
6
+ /// Shows the "what's new" bottom sheet, built on [KasyBottomSheet].
7
+ ///
8
+ /// Dismissing it — the Continue button, the drag handle, or tapping the dim
9
+ /// barrier outside — simply closes the sheet and returns the user to the screen
10
+ /// they were on; it never navigates anywhere. The version is already recorded as
11
+ /// seen before this is shown (see `MaybeShowUpdateBottomSheet`), so it won't pop
12
+ /// up again for the same version.
7
13
  Future<void> showUpdateBottomSheet({
8
14
  required BuildContext context,
9
15
  required String version,
10
- bool useRootNavigator = true,
11
16
  }) async {
12
- await showModalBottomSheet(
17
+ await showKasyBottomSheet<void>(
13
18
  context: context,
14
- useSafeArea: true,
15
- useRootNavigator: useRootNavigator,
16
- barrierColor: context.colors.background.withValues(alpha: 0.90),
17
19
  isScrollControlled: true,
18
20
  builder: (context) => _UpdateBottomSheet(version: version),
19
21
  );
@@ -28,130 +30,31 @@ class _UpdateBottomSheet extends StatelessWidget {
28
30
  Widget build(BuildContext context) {
29
31
  final translations = Translations.of(context).update_bottom_sheet;
30
32
 
31
- return Container(
32
- decoration: BoxDecoration(
33
- color: context.colors.surface,
34
- borderRadius: const BorderRadius.only(
35
- topLeft: Radius.circular(KasyRadius.xl),
36
- topRight: Radius.circular(KasyRadius.xl),
33
+ return KasyBottomSheet(
34
+ icon: KasyIcons.star,
35
+ title: translations.title,
36
+ message: 'Version $version',
37
+ body: ConstrainedBox(
38
+ constraints: BoxConstraints(
39
+ maxHeight: MediaQuery.sizeOf(context).height * 0.5,
37
40
  ),
38
- border: Border.all(
39
- color: context.colors.primary.withValues(alpha: 0.2),
40
- strokeAlign: BorderSide.strokeAlignOutside,
41
- width: 2,
41
+ child: ListView.separated(
42
+ shrinkWrap: true,
43
+ padding: EdgeInsets.zero,
44
+ itemBuilder: (context, index) =>
45
+ _UpdateHighlightTile(highlight: translations.highlights[index]),
46
+ separatorBuilder: (context, index) =>
47
+ const SizedBox(height: KasySpacing.sm),
48
+ itemCount: translations.highlights.length,
42
49
  ),
43
50
  ),
44
- child: SafeArea(
45
- minimum: const EdgeInsets.only(bottom: KasySpacing.md),
46
- child: Column(
47
- mainAxisSize: MainAxisSize.min,
48
- crossAxisAlignment: CrossAxisAlignment.start,
49
- children: [
50
- Flexible(
51
- flex: 0,
52
- child: Align(
53
- child: ClipRRect(
54
- borderRadius: const BorderRadius.only(
55
- topLeft: Radius.circular(KasyRadius.xl),
56
- topRight: Radius.circular(KasyRadius.xl),
57
- ),
58
- child: Image.asset(
59
- 'assets/images/update.png',
60
- fit: BoxFit.cover,
61
- ),
62
- ),
63
- ),
64
- ),
65
- Padding(
66
- padding: const EdgeInsets.fromLTRB(
67
- KasySpacing.lg,
68
- KasySpacing.lg,
69
- KasySpacing.md,
70
- 0,
71
- ),
72
- child: Row(
73
- children: [
74
- Container(
75
- width: 48,
76
- height: 48,
77
- decoration: BoxDecoration(
78
- color: context.colors.primary.withValues(alpha: 0.1),
79
- borderRadius: KasyRadius.mdBorderRadius,
80
- ),
81
- child: Icon(
82
- KasyIcons.star,
83
- color: context.colors.primary,
84
- size: 24,
85
- ),
86
- ),
87
- const SizedBox(width: KasySpacing.md),
88
- Expanded(
89
- child: Column(
90
- crossAxisAlignment: CrossAxisAlignment.start,
91
- children: [
92
- Text(
93
- translations.title,
94
- style: context.textTheme.headlineSmall?.copyWith(
95
- color: context.colors.onSurface,
96
- fontWeight: FontWeight.w700,
97
- ),
98
- ),
99
- Text(
100
- "Version $version",
101
- style: context.textTheme.bodyMedium?.copyWith(
102
- color: context.colors.muted,
103
- fontWeight: FontWeight.w500,
104
- ),
105
- ),
106
- ],
107
- ),
108
- ),
109
- KasyButton.iconOnly(
110
- icon: KasyIcons.close,
111
- variant: KasyButtonVariant.ghost,
112
- foregroundColor: context.colors.muted,
113
- onPressed: () => Navigator.of(context).pop(),
114
- semanticLabel: translations.title,
115
- ),
116
- ],
117
- ),
118
- ),
119
- const SizedBox(height: KasySpacing.lg),
120
- Flexible(
121
- child: ConstrainedBox(
122
- constraints: BoxConstraints(
123
- maxHeight: MediaQuery.of(context).size.height * 0.6,
124
- ),
125
- child: ListView.separated(
126
- shrinkWrap: true,
127
- padding: const EdgeInsets.symmetric(
128
- horizontal: KasySpacing.lg,
129
- ),
130
- itemBuilder: (context, index) {
131
- final highlight = translations.highlights[index];
132
- return _UpdateHighlightTile(highlight: highlight);
133
- },
134
- separatorBuilder: (context, index) =>
135
- const SizedBox(height: KasySpacing.sm),
136
- itemCount: translations.highlights.length,
137
- ),
138
- ),
139
- ),
140
- const SizedBox(height: KasySpacing.xl),
141
- Padding(
142
- padding: const EdgeInsets.symmetric(horizontal: KasySpacing.lg),
143
- child: SizedBox(
144
- width: double.infinity,
145
- child: KasyButton(
146
- label: translations.continue_button,
147
- expand: true,
148
- onPressed: () => Navigator.of(context).pop(),
149
- ),
150
- ),
151
- ),
152
- ],
51
+ actions: [
52
+ KasyButton(
53
+ label: translations.continue_button,
54
+ expand: true,
55
+ onPressed: () => Navigator.of(context).pop(),
153
56
  ),
154
- ),
57
+ ],
155
58
  );
156
59
  }
157
60
  }
@@ -115,13 +115,8 @@ class _DetailContainer extends StatelessWidget {
115
115
 
116
116
  @override
117
117
  Widget build(BuildContext context) {
118
- return Container(
119
- clipBehavior: Clip.antiAlias,
120
- decoration: BoxDecoration(
121
- color: context.colors.surface,
122
- borderRadius: BorderRadius.circular(24),
123
- border: Border.all(color: context.colors.border, width: 0.5),
124
- ),
118
+ return KasyCard(
119
+ borderRadius: BorderRadius.circular(KasyRadius.xl),
125
120
  child: child,
126
121
  );
127
122
  }
@@ -143,6 +138,15 @@ class _DetailPlaceholder extends StatelessWidget {
143
138
  showShadow: true,
144
139
  ),
145
140
  const SizedBox(height: KasySpacing.md),
141
+ Text(
142
+ t.home.cards.assistant_title,
143
+ textAlign: TextAlign.center,
144
+ style: context.textTheme.titleMedium?.copyWith(
145
+ fontWeight: FontWeight.w600,
146
+ color: context.colors.onBackground,
147
+ ),
148
+ ),
149
+ const SizedBox(height: KasySpacing.xs),
146
150
  Text(
147
151
  t.ai_chat.no_conversation_selected,
148
152
  textAlign: TextAlign.center,
@@ -30,6 +30,12 @@ class AiChatComposer extends StatefulWidget {
30
30
  class _AiChatComposerState extends State<AiChatComposer> {
31
31
  bool _canSend = false;
32
32
 
33
+ // Owns the field focus so we can intercept hardware Enter: plain Enter sends,
34
+ // Shift+Enter inserts a newline (the standard chat-composer behaviour). On
35
+ // touch keyboards (no hardware key event) the return key keeps inserting a
36
+ // newline and the send orb is used, so mobile is unchanged.
37
+ late final FocusNode _focusNode = FocusNode(onKeyEvent: _onKeyEvent);
38
+
33
39
  @override
34
40
  void initState() {
35
41
  super.initState();
@@ -40,9 +46,23 @@ class _AiChatComposerState extends State<AiChatComposer> {
40
46
  @override
41
47
  void dispose() {
42
48
  widget.controller.removeListener(_onTextChanged);
49
+ _focusNode.dispose();
43
50
  super.dispose();
44
51
  }
45
52
 
53
+ KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
54
+ if (event is! KeyDownEvent) return KeyEventResult.ignored;
55
+ final bool isEnter = event.logicalKey == LogicalKeyboardKey.enter ||
56
+ event.logicalKey == LogicalKeyboardKey.numpadEnter;
57
+ if (!isEnter) return KeyEventResult.ignored;
58
+ // Shift+Enter falls through to the field and inserts a newline.
59
+ if (HardwareKeyboard.instance.isShiftPressed) {
60
+ return KeyEventResult.ignored;
61
+ }
62
+ _handleSend();
63
+ return KeyEventResult.handled;
64
+ }
65
+
46
66
  bool _hasSendableText(String value) {
47
67
  final String trimmed = value.trim();
48
68
  return trimmed.isNotEmpty && trimmed.length <= kAiChatMaxMessageLength;
@@ -88,6 +108,7 @@ class _AiChatComposerState extends State<AiChatComposer> {
88
108
  Expanded(
89
109
  child: KasyTextField(
90
110
  controller: widget.controller,
111
+ focusNode: _focusNode,
91
112
  enabled: enabled,
92
113
  variant: KasyTextFieldVariant.embedded,
93
114
  hint: t.ai_chat.hint,
@@ -208,7 +208,7 @@ class _Header extends StatelessWidget {
208
208
  maxLines: 1,
209
209
  overflow: TextOverflow.ellipsis,
210
210
  style: context.textTheme.titleLarge?.copyWith(
211
- fontWeight: FontWeight.w800,
211
+ fontWeight: FontWeight.w700,
212
212
  color: context.colors.onBackground,
213
213
  ),
214
214
  ),
@@ -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
  }
@@ -392,6 +392,46 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
392
392
  /// Falls back to regular sign-in if the Facebook account already exists.
393
393
  @override
394
394
  Future<Credentials> signupFromAnonymousWithFacebook() async {
395
+ // Web: use the Firebase popup (link to the anonymous user when present), same
396
+ // pattern as Apple/Google on web.
397
+ if (kIsWeb) {
398
+ final facebookProvider = FacebookAuthProvider();
399
+ facebookProvider.addScope('email');
400
+ final currentUser = _auth.currentUser;
401
+ if (currentUser == null) {
402
+ try {
403
+ final result = await _popupOrCancel(
404
+ () => _auth.signInWithPopup(facebookProvider),
405
+ );
406
+ return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
407
+ } on FirebaseAuthException catch (e) {
408
+ if (_isUserCancelledPopup(e.code)) throw const UserCancelledSignInException();
409
+ rethrow;
410
+ }
411
+ }
412
+ try {
413
+ final result = await _popupOrCancel(() => currentUser.linkWithPopup(facebookProvider));
414
+ return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
415
+ } on FirebaseAuthException catch (e) {
416
+ if (_isUserCancelledPopup(e.code)) throw const UserCancelledSignInException();
417
+ if (e.code == 'credential-already-in-use') {
418
+ final anonymousUser = _auth.currentUser;
419
+ if (anonymousUser != null && anonymousUser.isAnonymous) {
420
+ await anonymousUser.delete();
421
+ }
422
+ final cred = e.credential;
423
+ if (cred != null) {
424
+ final result = await _auth.signInWithCredential(cred);
425
+ return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
426
+ }
427
+ final result = await _popupOrCancel(
428
+ () => _auth.signInWithPopup(facebookProvider),
429
+ );
430
+ return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
431
+ }
432
+ rethrow;
433
+ }
434
+ }
395
435
  final loginResult = await FacebookAuth.instance.login();
396
436
  if (loginResult.status == LoginStatus.cancelled) throw const UserCancelledSignInException();
397
437
  if (loginResult.status != LoginStatus.success || loginResult.accessToken == null) {
@@ -429,6 +469,27 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
429
469
 
430
470
  @override
431
471
  Future<Credentials> signinWithFacebook() async {
472
+ // Web has no native Facebook SDK flow: use the Firebase popup, same as Google
473
+ // and Apple on web. Requires the Facebook provider enabled in Firebase
474
+ // (kasy facebook) and the redirect URI registered on Meta.
475
+ if (kIsWeb) {
476
+ final facebookProvider = FacebookAuthProvider();
477
+ facebookProvider.addScope('email');
478
+ try {
479
+ final value = await _popupOrCancel(
480
+ () => _auth.signInWithPopup(facebookProvider),
481
+ );
482
+ return Credentials(
483
+ id: value.user!.uid,
484
+ token: value.credential?.token.toString() ?? '',
485
+ );
486
+ } on FirebaseAuthException catch (e) {
487
+ if (_isUserCancelledPopup(e.code)) {
488
+ throw const UserCancelledSignInException();
489
+ }
490
+ rethrow;
491
+ }
492
+ }
432
493
  final LoginResult loginResult = await FacebookAuth.instance.login();
433
494
  if (loginResult.status == LoginStatus.cancelled) throw const UserCancelledSignInException();
434
495
  if (loginResult.status != LoginStatus.success || loginResult.accessToken == null) {
@@ -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';
@@ -47,7 +49,7 @@ class SigninPage extends ConsumerWidget {
47
49
  const TextStyle())
48
50
  .copyWith(
49
51
  color: context.colors.muted,
50
- fontSize: 13,
52
+ fontSize: 13, // design-check: ignore — small "forgot password" link
51
53
  fontWeight: FontWeight.w500,
52
54
  );
53
55
  return PopScope(
@@ -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,18 @@ 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);
240
+ // Facebook on web works on the Firebase backend (signInWithPopup); on Supabase
241
+ // the web flow isn't wired yet (roadmap), so withFacebookWebSignin ships false
242
+ // there. Native (iOS/Android) always shows it.
243
+ const bool showFacebook = !kIsWeb || withFacebookWebSignin;
221
244
  return Row(
222
245
  children: [
223
246
  Expanded(
@@ -230,33 +253,38 @@ class _SocialSigninRow extends ConsumerWidget {
230
253
  ref.read(signinStateProvider.notifier).signinWithGoogle(),
231
254
  ),
232
255
  ),
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(),
256
+ if (showApple) ...[
257
+ const SizedBox(width: KasySpacing.sm),
258
+ Expanded(
259
+ child: _SocialSigninTile(
260
+ label: t.auth.signin.apple,
261
+ icon: Image.asset('assets/icons/apple.png', width: 20, height: 20),
262
+ onPressed: isSending
263
+ ? null
264
+ : () => ref
265
+ .read(signinStateProvider.notifier)
266
+ .signinWithApple(),
267
+ ),
242
268
  ),
243
- ),
244
- const SizedBox(width: KasySpacing.sm),
245
- Expanded(
246
- child: _SocialSigninTile(
247
- label: t.auth.signin.facebook,
248
- icon: Image.asset(
249
- 'assets/icons/facebook.png',
250
- width: 20,
251
- height: 20,
269
+ ],
270
+ if (showFacebook) ...[
271
+ const SizedBox(width: KasySpacing.sm),
272
+ Expanded(
273
+ child: _SocialSigninTile(
274
+ label: t.auth.signin.facebook,
275
+ icon: Image.asset(
276
+ 'assets/icons/facebook.png',
277
+ width: 20,
278
+ height: 20,
279
+ ),
280
+ onPressed: isSending
281
+ ? null
282
+ : () => ref
283
+ .read(signinStateProvider.notifier)
284
+ .signinWithFacebook(),
252
285
  ),
253
- onPressed: isSending
254
- ? null
255
- : () => ref
256
- .read(signinStateProvider.notifier)
257
- .signinWithFacebook(),
258
286
  ),
259
- ),
287
+ ],
260
288
  ],
261
289
  );
262
290
  }