kasy-cli 1.32.0 → 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 (60) 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 +50 -2
  7. package/lib/scaffold/CHANGELOG.json +18 -0
  8. package/lib/scaffold/backends/firebase/setup-from-scratch.js +164 -0
  9. package/lib/scaffold/backends/supabase/deploy.js +92 -0
  10. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +10 -0
  11. package/lib/scaffold/shared/generator-utils.js +18 -6
  12. package/lib/utils/apple-web.js +147 -0
  13. package/lib/utils/facebook.js +162 -0
  14. package/lib/utils/i18n/messages-en.js +62 -0
  15. package/lib/utils/i18n/messages-es.js +62 -0
  16. package/lib/utils/i18n/messages-pt.js +62 -0
  17. package/package.json +2 -2
  18. package/templates/firebase/AGENTS.md +87 -0
  19. package/templates/firebase/CLAUDE.md +16 -0
  20. package/templates/firebase/DESIGN_SYSTEM.md +234 -0
  21. package/templates/firebase/docs/auth-setup.en.md +2 -2
  22. package/templates/firebase/docs/auth-setup.es.md +2 -2
  23. package/templates/firebase/docs/auth-setup.pt.md +2 -2
  24. package/templates/firebase/lib/components/components.dart +1 -0
  25. package/templates/firebase/lib/components/kasy_app_bar.dart +4 -1
  26. package/templates/firebase/lib/components/kasy_screen.dart +114 -0
  27. package/templates/firebase/lib/components/kasy_toast.dart +39 -70
  28. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
  29. package/templates/firebase/lib/core/config/features.dart +5 -0
  30. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +46 -124
  31. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
  32. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -7
  33. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
  34. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
  35. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +61 -0
  36. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +21 -15
  37. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +20 -14
  38. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -2
  39. package/templates/firebase/lib/features/home/home_components_page.dart +7 -1
  40. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +32 -0
  41. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +165 -209
  42. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
  43. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
  44. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
  45. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -6
  46. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +77 -126
  47. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
  48. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
  49. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +2 -1
  50. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +17 -8
  51. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
  52. package/templates/firebase/lib/i18n/en.i18n.json +5 -4
  53. package/templates/firebase/lib/i18n/es.i18n.json +5 -4
  54. package/templates/firebase/lib/i18n/pt.i18n.json +5 -4
  55. package/templates/firebase/lib/router.dart +2 -0
  56. package/templates/firebase/pubspec.yaml +1 -1
  57. package/templates/firebase/tool/design_check.dart +152 -0
  58. package/templates/firebase/assets/images/review.png +0 -0
  59. package/templates/firebase/assets/images/update.png +0 -0
  60. package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
@@ -1,5 +1,5 @@
1
+ import 'package:flutter/foundation.dart' show kIsWeb;
1
2
  import 'package:flutter/material.dart';
2
- import 'package:flutter_animate/flutter_animate.dart';
3
3
  import 'package:flutter_riverpod/flutter_riverpod.dart';
4
4
  import 'package:go_router/go_router.dart';
5
5
  import 'package:kasy_kit/components/components.dart';
@@ -7,17 +7,25 @@ import 'package:kasy_kit/core/data/api/analytics_api.dart';
7
7
  import 'package:kasy_kit/core/rating/providers/rating_repository.dart';
8
8
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
9
9
  import 'package:kasy_kit/core/theme/theme.dart';
10
- import 'package:kasy_kit/core/widgets/responsive_layout.dart';
11
10
  import 'package:kasy_kit/i18n/translations.g.dart';
12
11
  import 'package:logger/logger.dart';
13
12
 
14
13
  /// Shows the in-app review dialog. Prefer a stable [context] (not a sheet that
15
14
  /// will be popped before the async work finishes).
15
+ ///
16
+ /// A clean [KasyDialog]: a title, a short message and two stacked actions
17
+ /// (write a review / suggest improvements). Dismissing via the dialog's own
18
+ /// close button just defers the next ask and keeps the user where they are.
16
19
  Future<bool> showReviewDialog(
17
20
  BuildContext context,
18
21
  WidgetRef ref, {
19
22
  bool force = false,
20
23
  }) async {
24
+ // Store reviews are a native-only concept (App Store / Play Store). On web
25
+ // there is nowhere to send the user, so the prompt never shows there.
26
+ if (kIsWeb) {
27
+ return false;
28
+ }
21
29
  if (!context.mounted) {
22
30
  return false;
23
31
  }
@@ -41,97 +49,42 @@ Future<bool> showReviewDialog(
41
49
  barrierDismissible: false,
42
50
  builder: (dialogContext) {
43
51
  ratingRepository.delay();
44
- return Animate(
45
- effects: const [
46
- FadeEffect(
47
- delay: Duration(milliseconds: 100),
48
- duration: Duration(milliseconds: 300),
49
- ),
50
- MoveEffect(
51
- delay: Duration(milliseconds: 100),
52
- duration: Duration(milliseconds: 450),
53
- curve: Curves.easeOut,
54
- begin: Offset(0, 50),
55
- end: Offset.zero,
56
- ),
57
- ],
58
- child: DeviceSizeBuilder(
59
- builder: (device) {
60
- final maxWidth = switch (device) {
61
- DeviceType.medium =>
62
- MediaQuery.of(dialogContext).size.width - KasySpacing.xl,
63
- _ => 550.0,
64
- };
65
- final translations = Translations.of(dialogContext).review_popup;
66
- return KasyDialog(
67
- title: translations.title,
68
- titleCentered: true,
69
- showCloseButton: false,
70
- body: Column(
71
- crossAxisAlignment: CrossAxisAlignment.stretch,
72
- mainAxisSize: MainAxisSize.min,
73
- children: [
74
- Stack(
75
- clipBehavior: Clip.none,
76
- children: [
77
- ClipRRect(
78
- borderRadius: BorderRadius.circular(KasyRadius.md),
79
- child: Image.asset(
80
- 'assets/images/review.png',
81
- fit: BoxFit.fitWidth,
82
- width: maxWidth,
83
- ),
84
- ),
85
- Positioned(
86
- top: KasySpacing.sm,
87
- left: KasySpacing.sm,
88
- child: CloseIcon(
89
- onExit: () {
90
- analytics.logEvent('rating_popup_close', {});
91
- rating.delay().then((_) {
92
- if (!dialogContext.mounted) return;
93
- Navigator.of(dialogContext).pop();
94
- });
95
- },
96
- ),
97
- ),
98
- ],
99
- ),
100
- const SizedBox(height: KasySpacing.md),
101
- Text(
102
- translations.description,
103
- textAlign: TextAlign.center,
104
- style: Theme.of(dialogContext).textTheme.bodyMedium,
105
- ),
106
- ],
107
- ),
108
- footer: Column(
109
- crossAxisAlignment: CrossAxisAlignment.stretch,
110
- children: [
111
- KasyButton(
112
- label: translations.rate_button,
113
- expand: true,
114
- onPressed: () {
115
- analytics.logEvent('rating_popup_show', {});
116
- ratingRepository.rate().then((_) => rating.review()).then(
117
- (_) {
118
- if (!dialogContext.mounted) return;
119
- Navigator.of(dialogContext).pop();
120
- },
121
- );
122
- },
123
- ),
124
- const SizedBox(height: KasySpacing.sm),
125
- KasyButton(
126
- label: translations.cancel_button,
127
- variant: KasyButtonVariant.soft,
128
- expand: true,
129
- onPressed: () => Navigator.of(dialogContext).pop(true),
130
- ),
131
- ],
132
- ),
133
- );
134
- },
52
+ final translations = Translations.of(dialogContext).review_popup;
53
+ return KasyDialog(
54
+ leadingIcon: KasyIcons.star,
55
+ iconTone: KasyDialogIconTone.info,
56
+ title: translations.title,
57
+ titleCentered: true,
58
+ message: translations.description,
59
+ onClose: () {
60
+ analytics.logEvent('rating_popup_close', {});
61
+ rating.delay();
62
+ Navigator.of(dialogContext).pop();
63
+ },
64
+ footer: Column(
65
+ crossAxisAlignment: CrossAxisAlignment.stretch,
66
+ children: [
67
+ KasyButton(
68
+ label: translations.rate_button,
69
+ expand: true,
70
+ onPressed: () {
71
+ analytics.logEvent('rating_popup_show', {});
72
+ ratingRepository.rate().then((_) => rating.review()).then(
73
+ (_) {
74
+ if (!dialogContext.mounted) return;
75
+ Navigator.of(dialogContext).pop();
76
+ },
77
+ );
78
+ },
79
+ ),
80
+ const SizedBox(height: KasySpacing.sm),
81
+ KasyButton(
82
+ label: translations.cancel_button,
83
+ variant: KasyButtonVariant.soft,
84
+ expand: true,
85
+ onPressed: () => Navigator.of(dialogContext).pop(true),
86
+ ),
87
+ ],
135
88
  ),
136
89
  );
137
90
  },
@@ -142,34 +95,3 @@ Future<bool> showReviewDialog(
142
95
  }
143
96
  return true;
144
97
  }
145
-
146
- class CloseIcon extends StatelessWidget {
147
- final VoidCallback onExit;
148
-
149
- const CloseIcon({super.key, required this.onExit});
150
-
151
- @override
152
- Widget build(BuildContext context) {
153
- return ClipOval(
154
- child: Material(
155
- color: Colors.transparent,
156
- child: InkWell(
157
- onTap: () => onExit.call(),
158
- child: Ink(
159
- width: 32,
160
- height: 32,
161
- decoration: BoxDecoration(
162
- color: context.colors.background,
163
- shape: BoxShape.circle,
164
- ),
165
- child: Icon(
166
- KasyIcons.close,
167
- color: context.colors.onBackground,
168
- size: KasyIconSize.lg,
169
- ),
170
- ),
171
- ),
172
- ),
173
- );
174
- }
175
- }
@@ -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: KasyIconSize.xl,
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
  ),
@@ -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) {
@@ -49,7 +49,7 @@ class SigninPage extends ConsumerWidget {
49
49
  const TextStyle())
50
50
  .copyWith(
51
51
  color: context.colors.muted,
52
- fontSize: 13,
52
+ fontSize: 13, // design-check: ignore — small "forgot password" link
53
53
  fontWeight: FontWeight.w500,
54
54
  );
55
55
  return PopScope(
@@ -237,6 +237,10 @@ class _SocialSigninRow extends ConsumerWidget {
237
237
  ? withAppleWebSignin
238
238
  : (defaultTargetPlatform == TargetPlatform.iOS ||
239
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;
240
244
  return Row(
241
245
  children: [
242
246
  Expanded(
@@ -263,22 +267,24 @@ class _SocialSigninRow extends ConsumerWidget {
263
267
  ),
264
268
  ),
265
269
  ],
266
- const SizedBox(width: KasySpacing.sm),
267
- Expanded(
268
- child: _SocialSigninTile(
269
- label: t.auth.signin.facebook,
270
- icon: Image.asset(
271
- 'assets/icons/facebook.png',
272
- width: 20,
273
- height: 20,
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(),
274
285
  ),
275
- onPressed: isSending
276
- ? null
277
- : () => ref
278
- .read(signinStateProvider.notifier)
279
- .signinWithFacebook(),
280
286
  ),
281
- ),
287
+ ],
282
288
  ],
283
289
  );
284
290
  }
@@ -216,6 +216,10 @@ class _SocialSignupRow extends ConsumerWidget {
216
216
  ? withAppleWebSignin
217
217
  : (defaultTargetPlatform == TargetPlatform.iOS ||
218
218
  defaultTargetPlatform == TargetPlatform.macOS);
219
+ // Facebook on web works on the Firebase backend (signInWithPopup); on Supabase
220
+ // the web flow isn't wired yet (roadmap), so withFacebookWebSignin ships false
221
+ // there. Native (iOS/Android) always shows it.
222
+ const bool showFacebook = !kIsWeb || withFacebookWebSignin;
219
223
  return Row(
220
224
  children: [
221
225
  Expanded(
@@ -242,22 +246,24 @@ class _SocialSignupRow extends ConsumerWidget {
242
246
  ),
243
247
  ),
244
248
  ],
245
- const SizedBox(width: KasySpacing.sm),
246
- Expanded(
247
- child: _SocialSignupTile(
248
- label: t.auth.signin.facebook,
249
- icon: Image.asset(
250
- 'assets/icons/facebook.png',
251
- width: 20,
252
- height: 20,
249
+ if (showFacebook) ...[
250
+ const SizedBox(width: KasySpacing.sm),
251
+ Expanded(
252
+ child: _SocialSignupTile(
253
+ label: t.auth.signin.facebook,
254
+ icon: Image.asset(
255
+ 'assets/icons/facebook.png',
256
+ width: 20,
257
+ height: 20,
258
+ ),
259
+ onPressed: isSending
260
+ ? null
261
+ : () => ref
262
+ .read(signinStateProvider.notifier)
263
+ .signinWithFacebook(),
253
264
  ),
254
- onPressed: isSending
255
- ? null
256
- : () => ref
257
- .read(signinStateProvider.notifier)
258
- .signinWithFacebook(),
259
265
  ),
260
- ),
266
+ ],
261
267
  ],
262
268
  );
263
269
  }
@@ -60,7 +60,6 @@ class _FeatureCardState extends State<FeatureCard> {
60
60
  Text(
61
61
  widget.title,
62
62
  style: context.textTheme.titleMedium?.copyWith(
63
- fontSize: 16,
64
63
  fontWeight: FontWeight.w700,
65
64
  ),
66
65
  ),
@@ -251,7 +250,7 @@ class _VoteCardState extends State<VoteCard>
251
250
  key: ValueKey('votes-${widget.id}-${widget.votes}'),
252
251
  widget.votes.toString(),
253
252
  style: context.textTheme.labelLarge?.copyWith(
254
- fontSize: 15,
253
+ fontSize: 15, // design-check: ignore — vote-count badge
255
254
  height: 1,
256
255
  fontWeight: FontWeight.w800,
257
256
  color: widget.textColor,