kasy-cli 1.32.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.md +1 -1
  2. package/bin/kasy.js +66 -2
  3. package/docs/cli-reference.md +7 -7
  4. package/lib/commands/apple-web.js +222 -0
  5. package/lib/commands/configure.js +3 -91
  6. package/lib/commands/doctor.js +20 -0
  7. package/lib/commands/facebook.js +189 -0
  8. package/lib/commands/new.js +61 -11
  9. package/lib/commands/release-version.js +234 -0
  10. package/lib/commands/update.js +27 -0
  11. package/lib/scaffold/CHANGELOG.json +27 -0
  12. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +24 -0
  13. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api_interface.dart +15 -0
  14. package/lib/scaffold/backends/api/patch/lib/features/authentication/repositories/authentication_repository.dart +27 -0
  15. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  16. package/lib/scaffold/backends/firebase/setup-from-scratch.js +199 -21
  17. package/lib/scaffold/backends/patch-base-hashes.json +66 -0
  18. package/lib/scaffold/backends/supabase/deploy.js +92 -0
  19. package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +2 -0
  20. package/lib/scaffold/backends/supabase/migrations/20240101000007_welcome_notification.sql +36 -23
  21. package/lib/scaffold/backends/supabase/migrations/20240101000014_subscription_trial_end.sql +6 -0
  22. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +92 -3
  23. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/repositories/authentication_repository.dart +36 -0
  24. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  25. package/lib/scaffold/generate.js +53 -4
  26. package/lib/scaffold/shared/generator-utils.js +18 -6
  27. package/lib/utils/apple-web.js +147 -0
  28. package/lib/utils/facebook.js +162 -0
  29. package/lib/utils/i18n/messages-en.js +85 -0
  30. package/lib/utils/i18n/messages-es.js +85 -0
  31. package/lib/utils/i18n/messages-pt.js +85 -0
  32. package/package.json +5 -2
  33. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +73 -0
  34. package/templates/firebase/AGENTS.md +170 -0
  35. package/templates/firebase/CLAUDE.md +16 -0
  36. package/templates/firebase/DESIGN_SYSTEM.md +269 -0
  37. package/templates/firebase/docs/auth-setup.en.md +4 -2
  38. package/templates/firebase/docs/auth-setup.es.md +4 -2
  39. package/templates/firebase/docs/auth-setup.pt.md +4 -2
  40. package/templates/firebase/firebase.json +56 -1
  41. package/templates/firebase/functions/src/authentication/triggers.ts +10 -6
  42. package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +5 -0
  43. package/templates/firebase/functions/src/core/data/entities/user_entity.ts +9 -1
  44. package/templates/firebase/functions/src/core/data/repositories/user_repository.ts +27 -0
  45. package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +3 -0
  46. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +4 -0
  47. package/templates/firebase/lib/components/components.dart +1 -0
  48. package/templates/firebase/lib/components/kasy_alert.dart +0 -1
  49. package/templates/firebase/lib/components/kasy_app_bar.dart +35 -17
  50. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +0 -1
  51. package/templates/firebase/lib/components/kasy_date_picker.dart +0 -5
  52. package/templates/firebase/lib/components/kasy_dialog.dart +0 -1
  53. package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +1 -1
  54. package/templates/firebase/lib/components/kasy_screen.dart +114 -0
  55. package/templates/firebase/lib/components/kasy_sidebar.dart +189 -120
  56. package/templates/firebase/lib/components/kasy_text_area.dart +0 -1
  57. package/templates/firebase/lib/components/kasy_text_field.dart +0 -1
  58. package/templates/firebase/lib/components/kasy_text_field_otp.dart +0 -1
  59. package/templates/firebase/lib/components/kasy_toast.dart +108 -73
  60. package/templates/firebase/lib/core/app_update/app_update_repository.dart +54 -0
  61. package/templates/firebase/lib/core/app_update/app_update_status.dart +14 -0
  62. package/templates/firebase/lib/core/app_update/update_available_sheet.dart +70 -0
  63. package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +40 -3
  64. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +82 -34
  65. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +6 -6
  66. package/templates/firebase/lib/core/bottom_menu/web_url.dart +20 -0
  67. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
  68. package/templates/firebase/lib/core/config/features.dart +5 -0
  69. package/templates/firebase/lib/core/data/api/remote_config_api.dart +38 -1
  70. package/templates/firebase/lib/core/guards/guard.dart +16 -2
  71. package/templates/firebase/lib/core/icons/kasy_icons.dart +3 -0
  72. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +2 -5
  73. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +48 -124
  74. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +14 -0
  75. package/templates/firebase/lib/core/states/components/maybe_show_update_available.dart +32 -0
  76. package/templates/firebase/lib/core/states/logout_action.dart +5 -1
  77. package/templates/firebase/lib/core/states/user_state_notifier.dart +85 -14
  78. package/templates/firebase/lib/core/theme/responsive_text_theme.dart +69 -0
  79. package/templates/firebase/lib/core/theme/texts.dart +90 -57
  80. package/templates/firebase/lib/core/theme/type_scale.dart +77 -0
  81. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +20 -6
  82. package/templates/firebase/lib/core/utils/image_bytes_loader.dart +12 -0
  83. package/templates/firebase/lib/core/utils/image_bytes_loader_io.dart +28 -0
  84. package/templates/firebase/lib/core/utils/image_bytes_loader_web.dart +56 -0
  85. package/templates/firebase/lib/core/web_screen_width.dart +15 -0
  86. package/templates/firebase/lib/core/web_screen_width_io.dart +3 -0
  87. package/templates/firebase/lib/core/web_screen_width_web.dart +10 -0
  88. package/templates/firebase/lib/core/web_viewport_scale.dart +61 -25
  89. package/templates/firebase/lib/core/widgets/focus_visibility.dart +89 -0
  90. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
  91. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -8
  92. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
  93. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +1 -2
  94. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +2 -3
  95. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -2
  96. package/templates/firebase/lib/features/authentication/api/auth_web_support.dart +12 -0
  97. package/templates/firebase/lib/features/authentication/api/auth_web_support_web.dart +25 -0
  98. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +266 -0
  99. package/templates/firebase/lib/features/authentication/api/authentication_api_interface.dart +22 -0
  100. package/templates/firebase/lib/features/authentication/navigation/post_login_navigation.dart +32 -0
  101. package/templates/firebase/lib/features/authentication/providers/phone_auth_notifier.dart +7 -7
  102. package/templates/firebase/lib/features/authentication/providers/signin_state_provider.dart +34 -10
  103. package/templates/firebase/lib/features/authentication/providers/signup_state_provider.dart +2 -2
  104. package/templates/firebase/lib/features/authentication/repositories/authentication_repository.dart +37 -0
  105. package/templates/firebase/lib/features/authentication/repositories/exceptions/authentication_exceptions.dart +8 -0
  106. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +2 -2
  107. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -2
  108. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +80 -15
  109. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +20 -14
  110. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +2 -2
  111. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +2 -2
  112. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -1
  113. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -2
  114. package/templates/firebase/lib/features/home/design_system_page.dart +134 -67
  115. package/templates/firebase/lib/features/home/home_components_page.dart +8 -1
  116. package/templates/firebase/lib/features/home/home_components_preview_page.dart +1 -3
  117. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +186 -56
  118. package/templates/firebase/lib/features/home/home_page.dart +4 -0
  119. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +169 -208
  120. package/templates/firebase/lib/features/notifications/providers/notifications_provider.dart +10 -0
  121. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
  122. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
  123. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
  124. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -4
  125. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +84 -128
  126. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +11 -0
  127. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +30 -3
  128. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +13 -4
  129. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
  130. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
  131. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +2 -1
  132. package/templates/firebase/lib/features/settings/settings_page.dart +152 -11
  133. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +58 -21
  134. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +12 -12
  135. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +1 -4
  136. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
  137. package/templates/firebase/lib/features/settings/ui/components/create_password_sheet.dart +141 -0
  138. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +0 -1
  139. package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +42 -38
  140. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +17 -2
  141. package/templates/firebase/lib/features/subscriptions/api/entities/subscription_entity.dart +3 -0
  142. package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +12 -11
  143. package/templates/firebase/lib/features/subscriptions/repositories/subscription_repository.dart +25 -2
  144. package/templates/firebase/lib/features/subscriptions/ui/component/active_premium_content.dart +319 -143
  145. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +1 -5
  146. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -5
  147. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +2 -5
  148. package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +1 -4
  149. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +1 -2
  150. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +2 -7
  151. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +2 -4
  152. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +1 -4
  153. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +0 -3
  154. package/templates/firebase/lib/i18n/en.i18n.json +54 -7
  155. package/templates/firebase/lib/i18n/es.i18n.json +54 -7
  156. package/templates/firebase/lib/i18n/pt.i18n.json +54 -7
  157. package/templates/firebase/lib/main.dart +11 -2
  158. package/templates/firebase/lib/router.dart +94 -13
  159. package/templates/firebase/pubspec.yaml +1 -1
  160. package/templates/firebase/test/core/data/api/fake_remote_config_api.dart +14 -0
  161. package/templates/firebase/test/core/states/user_state_notifier_test.dart +47 -3
  162. package/templates/firebase/test/core/web_viewport_scale_test.dart +68 -0
  163. package/templates/firebase/test/features/authentication/data/api/auth_api_fake.dart +15 -0
  164. package/templates/firebase/tool/design_check.dart +152 -0
  165. package/templates/firebase/web/index.html +162 -14
  166. package/templates/firebase/assets/images/review.png +0 -0
  167. package/templates/firebase/assets/images/update.png +0 -0
  168. package/templates/firebase/lib/core/guards/user_info_guard.dart +0 -61
  169. package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
@@ -7,7 +7,10 @@ import 'package:flutter/services.dart';
7
7
  import 'package:flutter_facebook_auth/flutter_facebook_auth.dart';
8
8
  import 'package:flutter_riverpod/flutter_riverpod.dart';
9
9
  import 'package:google_sign_in/google_sign_in.dart';
10
+ import 'package:kasy_kit/core/config/features.dart';
10
11
  import 'package:kasy_kit/core/data/entities/user_entity.dart';
12
+ import 'package:kasy_kit/features/authentication/api/auth_web_support.dart'
13
+ if (dart.library.js_interop) 'package:kasy_kit/features/authentication/api/auth_web_support_web.dart';
11
14
  import 'package:kasy_kit/features/authentication/api/authentication_api_interface.dart';
12
15
  import 'package:kasy_kit/features/authentication/api/popup_dismiss_watcher.dart'
13
16
  if (dart.library.js_interop) 'package:kasy_kit/features/authentication/api/popup_dismiss_watcher_web.dart';
@@ -48,6 +51,23 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
48
51
  }
49
52
  _user = event;
50
53
  });
54
+ if (kIsWeb) {
55
+ // Complete a pending mobile-web redirect sign-in (see signinWithGoogle /
56
+ // signinWithApple / signinWithFacebook). On desktop, or when no redirect
57
+ // is pending, this resolves immediately and is a no-op.
58
+ try {
59
+ await _auth.getRedirectResult();
60
+ } on FirebaseAuthException catch (e) {
61
+ if (e.code == 'account-exists-with-different-credential') {
62
+ Logger().w(
63
+ 'Redirect sign-in: an account already exists with this email '
64
+ 'under a different provider.',
65
+ );
66
+ } else if (!_isUserCancelledPopup(e.code)) {
67
+ Logger().w('Redirect sign-in failed: ${e.code}');
68
+ }
69
+ }
70
+ }
51
71
  var attempts = 0;
52
72
  while (!hasInit && attempts < 20) {
53
73
  await Future.delayed(const Duration(milliseconds: 500));
@@ -177,6 +197,38 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
177
197
  }
178
198
  }
179
199
 
200
+ /// Mobile-web OAuth: start a full-page redirect to the provider instead of a
201
+ /// popup. Mobile browsers handle popups unreliably (the popup result is often
202
+ /// lost when the browser reclaims the backgrounded opener tab), leaving the app
203
+ /// stuck "signing in". The redirect result is picked up at startup by
204
+ /// [getRedirectResult] in [init]. The page is navigating away as soon as
205
+ /// [signInWithRedirect] resolves, so this future intentionally never completes
206
+ /// — the caller stays in its loading state until the browser leaves.
207
+ /// See [isMobileWebBrowser]. When a Firebase anonymous user exists we link the
208
+ /// provider to it (preserving the UID, same as the popup path does); otherwise
209
+ /// we sign in. The linked/signed-in user is materialised at startup by
210
+ /// [getRedirectResult].
211
+ Future<Credentials> _signInWithRedirectWeb(AuthProvider provider) async {
212
+ final current = _auth.currentUser;
213
+ if (current != null && current.isAnonymous) {
214
+ await current.linkWithRedirect(provider);
215
+ } else {
216
+ await _auth.signInWithRedirect(provider);
217
+ }
218
+ return Completer<Credentials>().future;
219
+ }
220
+
221
+ /// Mobile-web account linking (Settings): link [provider] to [user] via a
222
+ /// full-page redirect instead of a popup, for the same reason as
223
+ /// [_signInWithRedirectWeb]. The link is completed at startup by
224
+ /// [getRedirectResult] in [init] (it reconnects to the persisted user). The
225
+ /// page navigates away as [linkWithRedirect] resolves, so this future never
226
+ /// completes — the caller stays in its loading state until the browser leaves.
227
+ Future<void> _linkWithRedirectWeb(User user, AuthProvider provider) async {
228
+ await user.linkWithRedirect(provider);
229
+ return Completer<void>().future;
230
+ }
231
+
180
232
  @override
181
233
  Future<Credentials> signinWithGoogle() async {
182
234
  if (kIsWeb) {
@@ -188,6 +240,9 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
188
240
  // googleProvider.setCustomParameters({
189
241
  // 'login_hint': 'user@example.com'
190
242
  // });
243
+ if (isMobileWebBrowser()) {
244
+ return _signInWithRedirectWeb(googleProvider);
245
+ }
191
246
  try {
192
247
  final credentials = await _popupOrCancel(
193
248
  () => _auth.signInWithPopup(googleProvider),
@@ -200,6 +255,9 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
200
255
  if (_isUserCancelledPopup(e.code)) {
201
256
  throw const UserCancelledSignInException();
202
257
  }
258
+ if (e.code == 'account-exists-with-different-credential') {
259
+ throw const EmailAlreadyRegisteredException();
260
+ }
203
261
  rethrow;
204
262
  }
205
263
  }
@@ -232,6 +290,9 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
232
290
  Future<Credentials> signupFromAnonymousWithGoogle() async {
233
291
  if (kIsWeb) {
234
292
  final googleProvider = GoogleAuthProvider();
293
+ if (isMobileWebBrowser()) {
294
+ return _signInWithRedirectWeb(googleProvider);
295
+ }
235
296
  try {
236
297
  final userCredential = await _popupOrCancel(
237
298
  () => _auth.currentUser != null
@@ -246,6 +307,9 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
246
307
  if (_isUserCancelledPopup(e.code)) {
247
308
  throw const UserCancelledSignInException();
248
309
  }
310
+ if (e.code == 'account-exists-with-different-credential') {
311
+ throw const EmailAlreadyRegisteredException();
312
+ }
249
313
  if (e.code == 'credential-already-in-use') {
250
314
  // Delete orphaned anonymous user before signing in with existing account.
251
315
  final anonymousUser = _auth.currentUser;
@@ -285,6 +349,9 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
285
349
  if (_isUserCancelledPopup(e.code)) {
286
350
  throw const UserCancelledSignInException();
287
351
  }
352
+ if (e.code == 'account-exists-with-different-credential') {
353
+ throw const EmailAlreadyRegisteredException();
354
+ }
288
355
  rethrow;
289
356
  }
290
357
  }
@@ -323,6 +390,9 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
323
390
  Future<Credentials> signinWithApple() async {
324
391
  final appleProvider = AppleAuthProvider();
325
392
  appleProvider.addScope('email');
393
+ if (kIsWeb && isMobileWebBrowser()) {
394
+ return _signInWithRedirectWeb(appleProvider);
395
+ }
326
396
  try {
327
397
  final value = kIsWeb
328
398
  ? await _popupOrCancel(() => _auth.signInWithPopup(appleProvider))
@@ -335,6 +405,9 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
335
405
  if (e.code == 'canceled' || e.code == 'popup-closed-by-user' || e.code == 'cancelled-popup-request') {
336
406
  throw const UserCancelledSignInException();
337
407
  }
408
+ if (e.code == 'account-exists-with-different-credential') {
409
+ throw const EmailAlreadyRegisteredException();
410
+ }
338
411
  rethrow;
339
412
  }
340
413
  }
@@ -345,6 +418,9 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
345
418
  Future<Credentials> signupFromAnonymousWithApple() async {
346
419
  final appleProvider = AppleAuthProvider();
347
420
  appleProvider.addScope('email');
421
+ if (kIsWeb && isMobileWebBrowser()) {
422
+ return _signInWithRedirectWeb(appleProvider);
423
+ }
348
424
  final currentUser = _auth.currentUser;
349
425
  if (currentUser == null) {
350
426
  // No anonymous session — fall back to regular sign-in.
@@ -357,6 +433,9 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
357
433
  if (_isUserCancelledPopup(e.code)) {
358
434
  throw const UserCancelledSignInException();
359
435
  }
436
+ if (e.code == 'account-exists-with-different-credential') {
437
+ throw const EmailAlreadyRegisteredException();
438
+ }
360
439
  rethrow;
361
440
  }
362
441
  }
@@ -392,6 +471,49 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
392
471
  /// Falls back to regular sign-in if the Facebook account already exists.
393
472
  @override
394
473
  Future<Credentials> signupFromAnonymousWithFacebook() async {
474
+ // Web: use the Firebase popup (link to the anonymous user when present), same
475
+ // pattern as Apple/Google on web.
476
+ if (kIsWeb) {
477
+ final facebookProvider = FacebookAuthProvider();
478
+ facebookProvider.addScope('email');
479
+ if (isMobileWebBrowser()) {
480
+ return _signInWithRedirectWeb(facebookProvider);
481
+ }
482
+ final currentUser = _auth.currentUser;
483
+ if (currentUser == null) {
484
+ try {
485
+ final result = await _popupOrCancel(
486
+ () => _auth.signInWithPopup(facebookProvider),
487
+ );
488
+ return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
489
+ } on FirebaseAuthException catch (e) {
490
+ if (_isUserCancelledPopup(e.code)) throw const UserCancelledSignInException();
491
+ rethrow;
492
+ }
493
+ }
494
+ try {
495
+ final result = await _popupOrCancel(() => currentUser.linkWithPopup(facebookProvider));
496
+ return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
497
+ } on FirebaseAuthException catch (e) {
498
+ if (_isUserCancelledPopup(e.code)) throw const UserCancelledSignInException();
499
+ if (e.code == 'credential-already-in-use') {
500
+ final anonymousUser = _auth.currentUser;
501
+ if (anonymousUser != null && anonymousUser.isAnonymous) {
502
+ await anonymousUser.delete();
503
+ }
504
+ final cred = e.credential;
505
+ if (cred != null) {
506
+ final result = await _auth.signInWithCredential(cred);
507
+ return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
508
+ }
509
+ final result = await _popupOrCancel(
510
+ () => _auth.signInWithPopup(facebookProvider),
511
+ );
512
+ return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
513
+ }
514
+ rethrow;
515
+ }
516
+ }
395
517
  final loginResult = await FacebookAuth.instance.login();
396
518
  if (loginResult.status == LoginStatus.cancelled) throw const UserCancelledSignInException();
397
519
  if (loginResult.status != LoginStatus.success || loginResult.accessToken == null) {
@@ -407,6 +529,9 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
407
529
  if (_isUserCancelledPopup(e.code)) {
408
530
  throw const UserCancelledSignInException();
409
531
  }
532
+ if (e.code == 'account-exists-with-different-credential') {
533
+ throw const EmailAlreadyRegisteredException();
534
+ }
410
535
  rethrow;
411
536
  }
412
537
  }
@@ -429,6 +554,33 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
429
554
 
430
555
  @override
431
556
  Future<Credentials> signinWithFacebook() async {
557
+ // Web has no native Facebook SDK flow: use the Firebase popup, same as Google
558
+ // and Apple on web. Requires the Facebook provider enabled in Firebase
559
+ // (kasy facebook) and the redirect URI registered on Meta.
560
+ if (kIsWeb) {
561
+ final facebookProvider = FacebookAuthProvider();
562
+ facebookProvider.addScope('email');
563
+ if (isMobileWebBrowser()) {
564
+ return _signInWithRedirectWeb(facebookProvider);
565
+ }
566
+ try {
567
+ final value = await _popupOrCancel(
568
+ () => _auth.signInWithPopup(facebookProvider),
569
+ );
570
+ return Credentials(
571
+ id: value.user!.uid,
572
+ token: value.credential?.token.toString() ?? '',
573
+ );
574
+ } on FirebaseAuthException catch (e) {
575
+ if (_isUserCancelledPopup(e.code)) {
576
+ throw const UserCancelledSignInException();
577
+ }
578
+ if (e.code == 'account-exists-with-different-credential') {
579
+ throw const EmailAlreadyRegisteredException();
580
+ }
581
+ rethrow;
582
+ }
583
+ }
432
584
  final LoginResult loginResult = await FacebookAuth.instance.login();
433
585
  if (loginResult.status == LoginStatus.cancelled) throw const UserCancelledSignInException();
434
586
  if (loginResult.status != LoginStatus.success || loginResult.accessToken == null) {
@@ -563,6 +715,120 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
563
715
  Future<String?> getCurrentUserDisplayName() async =>
564
716
  _auth.currentUser?.displayName;
565
717
 
718
+ @override
719
+ Future<String?> getCurrentUserPhotoUrl() async => _auth.currentUser?.photoURL;
720
+
721
+ @override
722
+ Future<List<String>> getLinkedProviders() async {
723
+ final providers = _auth.currentUser?.providerData;
724
+ if (providers == null) return const [];
725
+ return providers
726
+ .map((p) => switch (p.providerId) {
727
+ 'google.com' => 'google',
728
+ 'apple.com' => 'apple',
729
+ 'facebook.com' => 'facebook',
730
+ 'password' => 'email',
731
+ 'phone' => 'phone',
732
+ _ => p.providerId,
733
+ })
734
+ .toList();
735
+ }
736
+
737
+ @override
738
+ Future<void> setPassword(String password) async {
739
+ final user = _auth.currentUser;
740
+ if (user == null) throw Exception('No signed-in user');
741
+ final hasPassword =
742
+ user.providerData.any((p) => p.providerId == 'password');
743
+ if (hasPassword) {
744
+ // Already has an email/password credential: just change it.
745
+ await user.updatePassword(password);
746
+ return;
747
+ }
748
+ // Social-only account: attach an email/password credential to the same UID.
749
+ final email = user.email;
750
+ if (email == null || email.isEmpty) {
751
+ throw Exception('Account has no email to attach a password to');
752
+ }
753
+ await user.linkWithCredential(
754
+ EmailAuthProvider.credential(email: email, password: password),
755
+ );
756
+ }
757
+
758
+ @override
759
+ Future<List<String>> linkableSocialProviders() async {
760
+ final user = _auth.currentUser;
761
+ if (user == null) return const [];
762
+ final linked = user.providerData
763
+ .map((p) => switch (p.providerId) {
764
+ 'google.com' => 'google',
765
+ 'apple.com' => 'apple',
766
+ 'facebook.com' => 'facebook',
767
+ _ => p.providerId,
768
+ })
769
+ .toSet();
770
+ final result = <String>[];
771
+ if (!linked.contains('google')) result.add('google');
772
+ // Apple: native on iOS/macOS; on web only when the Apple web flow is set up.
773
+ final appleAvailable = kIsWeb
774
+ ? withAppleWebSignin
775
+ : (defaultTargetPlatform == TargetPlatform.iOS ||
776
+ defaultTargetPlatform == TargetPlatform.macOS);
777
+ if (appleAvailable && !linked.contains('apple')) result.add('apple');
778
+ return result;
779
+ }
780
+
781
+ @override
782
+ Future<void> linkSocialProvider(String provider) async {
783
+ final user = _auth.currentUser;
784
+ if (user == null) throw Exception('No signed-in user');
785
+ switch (provider) {
786
+ case 'google':
787
+ if (kIsWeb) {
788
+ if (isMobileWebBrowser()) {
789
+ return _linkWithRedirectWeb(user, GoogleAuthProvider());
790
+ }
791
+ await _popupOrCancel(() => user.linkWithPopup(GoogleAuthProvider()));
792
+ return;
793
+ }
794
+ try {
795
+ await GoogleSignIn.instance
796
+ .initialize(serverClientId: kGoogleWebClientId);
797
+ final googleUser = await GoogleSignIn.instance.authenticate();
798
+ final googleAuth = googleUser.authentication;
799
+ await user.linkWithCredential(
800
+ GoogleAuthProvider.credential(idToken: googleAuth.idToken),
801
+ );
802
+ } on GoogleSignInException catch (e) {
803
+ if (e.code == GoogleSignInExceptionCode.canceled) {
804
+ throw const UserCancelledSignInException();
805
+ }
806
+ rethrow;
807
+ }
808
+ case 'apple':
809
+ final appleProvider = AppleAuthProvider()..addScope('email');
810
+ if (kIsWeb && isMobileWebBrowser()) {
811
+ return _linkWithRedirectWeb(user, appleProvider);
812
+ }
813
+ try {
814
+ if (kIsWeb) {
815
+ await _popupOrCancel(() => user.linkWithPopup(appleProvider));
816
+ } else {
817
+ await user.linkWithProvider(appleProvider);
818
+ }
819
+ } on FirebaseAuthException catch (e) {
820
+ if (e.code == 'canceled' ||
821
+ e.code == 'popup-closed-by-user' ||
822
+ e.code == 'cancelled-popup-request') {
823
+ throw const UserCancelledSignInException();
824
+ }
825
+ rethrow;
826
+ }
827
+ default:
828
+ throw Exception('Unsupported provider: $provider');
829
+ }
830
+ }
831
+
566
832
  String _normalizePhoneNumber(String phoneNumber) {
567
833
  String normalized = phoneNumber.replaceAll(RegExp(r'\D'), '');
568
834
  if (!normalized.startsWith('+')) {
@@ -77,6 +77,28 @@ abstract class AuthenticationApi implements OnStartService {
77
77
 
78
78
  /// Returns the display name of the currently authenticated Firebase Auth user (if any).
79
79
  Future<String?> getCurrentUserDisplayName();
80
+
81
+ /// Returns the photo URL of the current user (e.g. Google profile picture), or null.
82
+ Future<String?> getCurrentUserPhotoUrl();
83
+
84
+ /// Returns ALL sign-in providers linked to the current account, each
85
+ /// normalised to 'google' | 'apple' | 'facebook' | 'email' | 'phone'.
86
+ /// Empty when there is no user or none can be determined.
87
+ Future<List<String>> getLinkedProviders();
88
+
89
+ /// Sets or updates an email/password credential for the current user, so a
90
+ /// social-only account can also sign in with email + password (same account,
91
+ /// no duplicate). Throws if there is no signed-in user.
92
+ Future<void> setPassword(String password);
93
+
94
+ /// Returns the social providers ('google'|'apple') the current user can still
95
+ /// link to their account (already-linked ones excluded). Empty on backends
96
+ /// that link identities automatically (e.g. Supabase).
97
+ Future<List<String>> linkableSocialProviders();
98
+
99
+ /// Links the given social provider ('google'|'apple') to the current account,
100
+ /// so it can also be used to sign in. Same account, no duplicate.
101
+ Future<void> linkSocialProvider(String provider);
80
102
  }
81
103
 
82
104
  class PhoneAlreadyLinkedException implements Exception {
@@ -0,0 +1,32 @@
1
+ import 'package:flutter/widgets.dart';
2
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+ import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
4
+ import 'package:kasy_kit/core/bottom_menu/web_url.dart';
5
+ import 'package:kasy_kit/router.dart';
6
+
7
+ /// Sends a freshly-authenticated user to Home — the single source of truth for
8
+ /// post-login navigation.
9
+ ///
10
+ /// EVERY sign-in flow (email/password, Google, Google Play Games, Apple,
11
+ /// Facebook, phone and sign-up) calls this instead of navigating on its own, so
12
+ /// they all behave identically:
13
+ /// 1. force the next bottom-bar mount to Home — a fresh login must land on
14
+ /// Home, never on whatever tab (or stale web URL) a previous session left
15
+ /// behind. This is a one-shot flag, so it wins the race against the auth
16
+ /// page's own `redirectIfAuthenticated` (both navigate to `/`);
17
+ /// 2. navigate Home;
18
+ /// 3. on web, rewrite the address bar to `/` — the bottom bar (Bart) writes
19
+ /// tab URLs directly and never writes Home's, so without this the URL stays
20
+ /// frozen on the previous tab (e.g. `/settings`) while the screen is Home.
21
+ ///
22
+ /// Adding a new sign-in method later? Call this one function and the tab reset
23
+ /// comes for free — there is nothing to remember and nothing to duplicate, which
24
+ /// is exactly what kept the old per-flow `go('/')` calls drifting out of sync.
25
+ void goHomeAfterLogin(Ref ref) {
26
+ requestHomeOnNextMount();
27
+ ref.read(goRouterProvider).go('/');
28
+ // After the frame that mounts the Home shell, correct the address bar. Home
29
+ // never re-pushes the URL, so this `replaceState('/')` sticks.
30
+ WidgetsBinding.instance
31
+ .addPostFrameCallback((_) => syncBrowserUrl('/'));
32
+ }
@@ -1,12 +1,13 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:kasy_kit/core/data/models/user.dart';
3
+ import 'package:kasy_kit/core/states/translations.dart';
3
4
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
4
5
  import 'package:kasy_kit/core/toast/toast_service.dart';
5
6
  import 'package:kasy_kit/features/authentication/api/authentication_api_interface.dart';
7
+ import 'package:kasy_kit/features/authentication/navigation/post_login_navigation.dart';
6
8
  import 'package:kasy_kit/features/authentication/providers/models/phone_signin_state.dart';
7
9
  import 'package:kasy_kit/features/authentication/repositories/authentication_repository.dart';
8
10
  import 'package:kasy_kit/features/authentication/repositories/exceptions/authentication_exceptions.dart';
9
- import 'package:kasy_kit/router.dart';
10
11
  import 'package:logger/logger.dart';
11
12
  import 'package:riverpod_annotation/riverpod_annotation.dart';
12
13
 
@@ -97,14 +98,13 @@ class PhoneAuthNotifier extends _$PhoneAuthNotifier {
97
98
  false => await _authRepository.verifyPhoneAuth(verificationId, otp),
98
99
  };
99
100
 
100
- ref
101
- .read(toastProvider)
102
- .success(
103
- title: "Success",
104
- text: "You are now signed in with your phone number",
101
+ final tr = ref.read(translationsProvider).phone_auth;
102
+ ref.read(toastProvider).success(
103
+ title: tr.signin_success_title,
104
+ text: tr.signin_success_text,
105
105
  );
106
106
  await Future.delayed(const Duration(seconds: 2));
107
- ref.read(goRouterProvider).go("/");
107
+ goHomeAfterLogin(ref);
108
108
  } on PhoneAuthException catch (e) {
109
109
  state = state.copyWith(isLoading: false, error: e.message);
110
110
  } on PhoneAlreadyLinkedException {
@@ -3,12 +3,12 @@ import 'package:kasy_kit/core/data/models/user.dart';
3
3
  import 'package:kasy_kit/core/states/translations.dart';
4
4
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
5
5
  import 'package:kasy_kit/core/toast/toast_service.dart';
6
+ import 'package:kasy_kit/features/authentication/navigation/post_login_navigation.dart';
6
7
  import 'package:kasy_kit/features/authentication/providers/models/email.dart';
7
8
  import 'package:kasy_kit/features/authentication/providers/models/password.dart';
8
9
  import 'package:kasy_kit/features/authentication/providers/models/signin_state.dart';
9
10
  import 'package:kasy_kit/features/authentication/repositories/authentication_repository.dart';
10
11
  import 'package:kasy_kit/features/authentication/repositories/exceptions/authentication_exceptions.dart';
11
- import 'package:kasy_kit/router.dart';
12
12
  import 'package:riverpod_annotation/riverpod_annotation.dart';
13
13
 
14
14
  part 'signin_state_provider.g.dart';
@@ -54,7 +54,7 @@ class SigninStateNotifier extends _$SigninStateNotifier {
54
54
  if (!ref.mounted) return;
55
55
  await _userStateNotifier.onSignin();
56
56
  if (!ref.mounted) return;
57
- ref.read(goRouterProvider).go('/');
57
+ goHomeAfterLogin(ref);
58
58
  } catch (e, trace) {
59
59
  debugPrint("Error while signing up: $e, $trace");
60
60
  if (!ref.mounted) return;
@@ -84,13 +84,19 @@ class SigninStateNotifier extends _$SigninStateNotifier {
84
84
  if (!ref.mounted) return;
85
85
  await _userStateNotifier.onSignin();
86
86
  if (!ref.mounted) return;
87
- ref.read(goRouterProvider).go('/');
87
+ goHomeAfterLogin(ref);
88
88
  } catch (e, trace) {
89
89
  if (!ref.mounted) return;
90
90
  state = SigninState(email: state.email, password: state.password);
91
91
  if (e is UserCancelledSignInException) return;
92
92
  debugPrint("Error while signing up: $e, $trace");
93
- ref.read(toastProvider).error(title: 'Error', text: 'Cannot signin with Google');
93
+ final tr = ref.read(translationsProvider).auth.signin;
94
+ ref.read(toastProvider).error(
95
+ title: tr.error_title,
96
+ text: e is EmailAlreadyRegisteredException
97
+ ? tr.email_already_registered
98
+ : tr.social_error(provider: 'Google'),
99
+ );
94
100
  }
95
101
  }
96
102
 
@@ -104,13 +110,19 @@ class SigninStateNotifier extends _$SigninStateNotifier {
104
110
  if (!ref.mounted) return;
105
111
  await _userStateNotifier.onSignin();
106
112
  if (!ref.mounted) return;
107
- ref.read(goRouterProvider).go('/');
113
+ goHomeAfterLogin(ref);
108
114
  } catch (e, trace) {
109
115
  if (!ref.mounted) return;
110
116
  state = SigninState(email: state.email, password: state.password);
111
117
  if (e is UserCancelledSignInException) return;
112
118
  debugPrint("Error while signing up: $e, $trace");
113
- ref.read(toastProvider).error(title: 'Error', text: 'Cannot signin with Google Play Games');
119
+ final tr = ref.read(translationsProvider).auth.signin;
120
+ ref.read(toastProvider).error(
121
+ title: tr.error_title,
122
+ text: e is EmailAlreadyRegisteredException
123
+ ? tr.email_already_registered
124
+ : tr.social_error(provider: 'Google Play Games'),
125
+ );
114
126
  }
115
127
  }
116
128
 
@@ -129,13 +141,19 @@ class SigninStateNotifier extends _$SigninStateNotifier {
129
141
  if (!ref.mounted) return;
130
142
  await _userStateNotifier.onSignin();
131
143
  if (!ref.mounted) return;
132
- ref.read(goRouterProvider).go('/');
144
+ goHomeAfterLogin(ref);
133
145
  } catch (e, trace) {
134
146
  if (!ref.mounted) return;
135
147
  state = SigninState(email: state.email, password: state.password);
136
148
  if (e is UserCancelledSignInException) return;
137
149
  debugPrint("Error while signing up: $e, $trace");
138
- ref.read(toastProvider).error(title: 'Error', text: 'Cannot signin with Apple');
150
+ final tr = ref.read(translationsProvider).auth.signin;
151
+ ref.read(toastProvider).error(
152
+ title: tr.error_title,
153
+ text: e is EmailAlreadyRegisteredException
154
+ ? tr.email_already_registered
155
+ : tr.social_error(provider: 'Apple'),
156
+ );
139
157
  }
140
158
  }
141
159
 
@@ -154,13 +172,19 @@ class SigninStateNotifier extends _$SigninStateNotifier {
154
172
  if (!ref.mounted) return;
155
173
  await _userStateNotifier.onSignin();
156
174
  if (!ref.mounted) return;
157
- ref.read(goRouterProvider).go('/');
175
+ goHomeAfterLogin(ref);
158
176
  } catch (e, trace) {
159
177
  if (!ref.mounted) return;
160
178
  state = SigninState(email: state.email, password: state.password);
161
179
  if (e is UserCancelledSignInException) return;
162
180
  debugPrint("Error while signing up: $e, $trace");
163
- ref.read(toastProvider).error(title: 'Error', text: 'Cannot signin with Facebook');
181
+ final tr = ref.read(translationsProvider).auth.signin;
182
+ ref.read(toastProvider).error(
183
+ title: tr.error_title,
184
+ text: e is EmailAlreadyRegisteredException
185
+ ? tr.email_already_registered
186
+ : tr.social_error(provider: 'Facebook'),
187
+ );
164
188
  }
165
189
  }
166
190
  }
@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
2
2
  import 'package:kasy_kit/core/states/translations.dart';
3
3
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
4
4
  import 'package:kasy_kit/core/toast/toast_service.dart';
5
+ import 'package:kasy_kit/features/authentication/navigation/post_login_navigation.dart';
5
6
  import 'package:kasy_kit/features/authentication/providers/models/email.dart';
6
7
  import 'package:kasy_kit/features/authentication/providers/models/password.dart';
7
8
  import 'package:kasy_kit/features/authentication/providers/models/signup_state.dart';
8
9
  import 'package:kasy_kit/features/authentication/repositories/authentication_repository.dart';
9
- import 'package:kasy_kit/router.dart';
10
10
  import 'package:riverpod_annotation/riverpod_annotation.dart';
11
11
 
12
12
  part 'signup_state_provider.g.dart';
@@ -53,7 +53,7 @@ class SignupStateNotifier extends _$SignupStateNotifier {
53
53
  await Future.delayed(const Duration(milliseconds: 1500));
54
54
  await _userStateNotifier.onSignin();
55
55
  if (!ref.mounted) return;
56
- ref.read(goRouterProvider).go('/');
56
+ goHomeAfterLogin(ref);
57
57
  } catch (e, trace) {
58
58
  debugPrint("Error while signing up: $e, $trace");
59
59
  state = SignupState(email: state.email, password: state.password);
@@ -69,6 +69,23 @@ abstract class AuthenticationRepository {
69
69
  /// Returns the display name of the authenticated Firebase Auth user.
70
70
  Future<String?> getCurrentUserDisplayName();
71
71
 
72
+ /// Returns the photo URL of the current user (e.g. Google picture), or null.
73
+ Future<String?> getCurrentUserPhotoUrl();
74
+
75
+ /// Returns all sign-in providers linked to the current account
76
+ /// ('google'|'apple'|'facebook'|'email'|'phone'). Empty if none.
77
+ Future<List<String>> getLinkedProviders();
78
+
79
+ /// Sets or updates an email/password credential for the current user, so a
80
+ /// social-only account can also sign in with email + password.
81
+ Future<void> setPassword(String password);
82
+
83
+ /// Social providers the current user can still link to their account.
84
+ Future<List<String>> linkableSocialProviders();
85
+
86
+ /// Links a social provider ('google'|'apple') to the current account.
87
+ Future<void> linkSocialProvider(String provider);
88
+
72
89
  /// Signin with Google Play Games account on Android
73
90
  Future<void> signinWithGooglePlayGames();
74
91
 
@@ -263,6 +280,26 @@ class HttpAuthenticationRepository implements AuthenticationRepository {
263
280
  Future<String?> getCurrentUserDisplayName() =>
264
281
  _authenticationApi.getCurrentUserDisplayName();
265
282
 
283
+ @override
284
+ Future<String?> getCurrentUserPhotoUrl() =>
285
+ _authenticationApi.getCurrentUserPhotoUrl();
286
+
287
+ @override
288
+ Future<List<String>> getLinkedProviders() =>
289
+ _authenticationApi.getLinkedProviders();
290
+
291
+ @override
292
+ Future<void> setPassword(String password) =>
293
+ _authenticationApi.setPassword(password);
294
+
295
+ @override
296
+ Future<List<String>> linkableSocialProviders() =>
297
+ _authenticationApi.linkableSocialProviders();
298
+
299
+ @override
300
+ Future<void> linkSocialProvider(String provider) =>
301
+ _authenticationApi.linkSocialProvider(provider);
302
+
266
303
  @override
267
304
  Future<void> signinWithGooglePlayGames() async {
268
305
  try {
@@ -70,6 +70,14 @@ class UserCancelledSignInException implements Exception {
70
70
  const UserCancelledSignInException();
71
71
  }
72
72
 
73
+ /// Thrown when a social sign-in is attempted with an email that already has an
74
+ /// account created with a different method (e.g. email/password or another
75
+ /// social provider). Lets the UI show a clear "email already in use" message
76
+ /// instead of a generic error, and avoids creating a duplicate account.
77
+ class EmailAlreadyRegisteredException implements Exception {
78
+ const EmailAlreadyRegisteredException();
79
+ }
80
+
73
81
  class PhoneAuthException extends ApiError {
74
82
  PhoneAuthException({
75
83
  required super.code,