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
@@ -0,0 +1,20 @@
1
+ import 'package:flutter/foundation.dart' show kIsWeb;
2
+ import 'package:universal_html/html.dart' as html;
3
+
4
+ /// Forces the browser address bar to [path] on web (no-op on native).
5
+ ///
6
+ /// Why this exists: the bottom bar (Bart) writes each tab's URL directly via
7
+ /// `history.pushState`, bypassing GoRouter — and it never writes the Home tab's
8
+ /// URL (Bart short-circuits when the tab index doesn't change, and Home is the
9
+ /// default index). So after a fresh login forces Home, GoRouter is at `/` but
10
+ /// the address bar still shows the previous session's tab (e.g. `/settings`),
11
+ /// and `go('/')` is a no-op because GoRouter already considers itself at `/`.
12
+ ///
13
+ /// `replaceState` (not `pushState`) so the stale entry is corrected in place,
14
+ /// without adding a bogus history step the user could "back" into.
15
+ void syncBrowserUrl(String path) {
16
+ if (!kIsWeb) {
17
+ return;
18
+ }
19
+ html.window.history.replaceState(null, '', path);
20
+ }
@@ -71,6 +71,28 @@ class KasyChromeVisibility {
71
71
  }
72
72
  }
73
73
 
74
+ /// Brings the chrome back whenever a route is pushed onto or popped from the
75
+ /// root navigator. Tab switches already reset via [BartScaffold.onRouteChanged],
76
+ /// but detail screens (feedback, reminders, …) are pushed on the root navigator
77
+ /// and bypass that — so without this, returning to a screen that had scrolled
78
+ /// its chrome away would leave the app bar and bottom menu stuck hidden.
79
+ ///
80
+ /// Only the chrome is restored; the destination screen keeps its scroll position
81
+ /// (the framework preserves it), matching how large apps handle back navigation.
82
+ class KasyChromeVisibilityObserver extends NavigatorObserver {
83
+ void _reset() => KasyChromeVisibility.instance.resetShown();
84
+
85
+ @override
86
+ void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) => _reset();
87
+
88
+ @override
89
+ void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) => _reset();
90
+
91
+ @override
92
+ void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) =>
93
+ _reset();
94
+ }
95
+
74
96
  // ---------------------------------------------------------------------------
75
97
  // Configuration — edit these to change the experience your app ships with.
76
98
  // ---------------------------------------------------------------------------
@@ -12,6 +12,11 @@ const bool withLocalReminders = true;
12
12
  /// the CLI generates them as `false`. Apple shows on iOS/macOS regardless; it is
13
13
  /// hidden on Android (also needs a paid Service ID, and the native flow throws).
14
14
  const bool withAppleWebSignin = true;
15
+ /// When true, the Facebook sign-in button is shown on WEB. Facebook-on-web works
16
+ /// on the Firebase backend (signInWithPopup) after `kasy facebook`; on Supabase the
17
+ /// web flow isn't wired yet (roadmap), so the CLI ships this `false` there. Native
18
+ /// (iOS/Android) always shows the Facebook button regardless of this flag.
19
+ const bool withFacebookWebSignin = false;
15
20
  /// When true, the app includes web support:
16
21
  /// - anonymous sign-up is disabled on web (user is redirected to /signin)
17
22
  /// - onboarding is skipped on web
@@ -1,4 +1,5 @@
1
1
  import 'package:firebase_remote_config/firebase_remote_config.dart';
2
+ import 'package:flutter/foundation.dart' show kDebugMode;
2
3
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
4
  import 'package:kasy_kit/core/initializer/onstart_service.dart';
4
5
  import 'package:logger/logger.dart';
@@ -17,6 +18,18 @@ final remoteConfigApiProvider = Provider<RemoteConfigApi>(
17
18
  api: remoteConfigWrapper,
18
19
  ),
19
20
  ),
21
+ appUpdate: AppUpdateConfigs(
22
+ latestVersion: FirebaseRemoteConfigData<String>(
23
+ key: 'app_latest_version',
24
+ api: remoteConfigWrapper,
25
+ defaultValue: '0.0.0',
26
+ ),
27
+ minVersion: FirebaseRemoteConfigData<String>(
28
+ key: 'app_min_version',
29
+ api: remoteConfigWrapper,
30
+ defaultValue: '0.0.0',
31
+ ),
32
+ ),
20
33
  );
21
34
  },
22
35
  );
@@ -48,10 +61,12 @@ final remoteConfigApiProvider = Provider<RemoteConfigApi>(
48
61
  class RemoteConfigApi implements OnStartService {
49
62
  final FirebaseRemoteConfig _remoteConfig;
50
63
  final SubscriptionConfigs subscription; // this is an example of group of keys
64
+ final AppUpdateConfigs appUpdate;
51
65
 
52
66
  RemoteConfigApi({
53
67
  required FirebaseRemoteConfig remoteConfig,
54
68
  required this.subscription,
69
+ required this.appUpdate,
55
70
  }) : _remoteConfig = remoteConfig;
56
71
 
57
72
  @override
@@ -59,7 +74,11 @@ class RemoteConfigApi implements OnStartService {
59
74
  try {
60
75
  await _remoteConfig.setConfigSettings(RemoteConfigSettings(
61
76
  fetchTimeout: const Duration(seconds: 60),
62
- minimumFetchInterval: const Duration(hours: 1),
77
+ // In debug we fetch every launch so config changes (e.g. testing the
78
+ // "update available" versions) show up immediately; release throttles
79
+ // to once an hour as recommended.
80
+ minimumFetchInterval:
81
+ kDebugMode ? Duration.zero : const Duration(hours: 1),
63
82
  ));
64
83
  await _remoteConfig.fetchAndActivate();
65
84
  } catch (e) {
@@ -168,3 +187,21 @@ class SubscriptionConfigs {
168
187
  });
169
188
  }
170
189
 
190
+ /// Remote keys that drive the "update available" prompt. Set these in the
191
+ /// Firebase Remote Config console (works on every backend, since Firebase Core
192
+ /// is always initialized):
193
+ /// - `app_latest_version`: the newest version published to the stores. When the
194
+ /// installed version is older, an *optional* update sheet is shown.
195
+ /// - `app_min_version`: the oldest version still allowed. When the installed
196
+ /// version is older, a *forced* (blocking) update sheet is shown.
197
+ /// Both default to `0.0.0` (feature effectively off until you set real values).
198
+ class AppUpdateConfigs {
199
+ final RemoteConfigData<String> latestVersion;
200
+ final RemoteConfigData<String> minVersion;
201
+
202
+ AppUpdateConfigs({
203
+ required this.latestVersion,
204
+ required this.minVersion,
205
+ });
206
+ }
207
+
@@ -1,5 +1,6 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:go_router/go_router.dart';
3
+ import 'package:kasy_kit/core/theme/theme.dart';
3
4
 
4
5
  enum RouterType {
5
6
  goRouter,
@@ -30,18 +31,27 @@ class Guard extends StatelessWidget {
30
31
  future: canActivate,
31
32
  builder: (_, result) {
32
33
  if (!result.hasData || result.hasError) {
33
- return Container();
34
+ return _loadingScreen(context);
34
35
  }
35
36
  final bool canActivate = result.data!;
36
37
  if (canActivate) {
37
38
  return child;
38
39
  }
39
40
  redirect(context);
40
- return loading ?? Container();
41
+ return loading ?? _loadingScreen(context);
41
42
  },
42
43
  );
43
44
  }
44
45
 
46
+ /// Themed placeholder shown while the guard resolves or redirects. Never a
47
+ /// bare [Container] (which paints nothing and shows the black window behind).
48
+ Widget _loadingScreen(BuildContext context) {
49
+ return Material(
50
+ color: context.colors.background,
51
+ child: const Center(child: CircularProgressIndicator.adaptive()),
52
+ );
53
+ }
54
+
45
55
  void redirect(BuildContext context) {
46
56
  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
47
57
  if (!context.mounted) return;
@@ -64,5 +74,9 @@ class Guard extends StatelessWidget {
64
74
  Navigator.of(context).pushReplacementNamed(fallbackRoute);
65
75
  }
66
76
  });
77
+ // addPostFrameCallback does not schedule a frame on its own; if the app is
78
+ // idle the redirect would otherwise wait until the next input event (the
79
+ // "stuck until you tap the screen" symptom). Force a frame so it runs now.
80
+ WidgetsBinding.instance.scheduleFrame();
67
81
  }
68
82
  }
@@ -50,6 +50,9 @@ abstract final class KasyIcons {
50
50
 
51
51
  /// Collapse the left panel (sidebar) — narrow/expand toggle.
52
52
  static const IconData panelLeft = LucideIcons.panelLeft300;
53
+
54
+ /// Hamburger — opens the navigation drawer (e.g. from an app-bar leading).
55
+ static const IconData menu = LucideIcons.menu300;
53
56
  static const IconData cameraAlt = LucideIcons.camera400;
54
57
  static const IconData check = LucideIcons.check300;
55
58
  static const IconData checkCircle = LucideIcons.circleCheck300;
@@ -133,9 +133,7 @@ class RateBannerWidget extends StatelessWidget {
133
133
  Text(
134
134
  title,
135
135
  textAlign: TextAlign.center,
136
- style: TextStyle(
137
- fontSize: 17,
138
- fontWeight: FontWeight.w600,
136
+ style: context.kasyTextTheme.sectionTitle.copyWith(
139
137
  color: cs.onSurface,
140
138
  height: 1.3,
141
139
  ),
@@ -144,8 +142,7 @@ class RateBannerWidget extends StatelessWidget {
144
142
  Text(
145
143
  text,
146
144
  textAlign: TextAlign.center,
147
- style: TextStyle(
148
- fontSize: 14,
145
+ style: context.textTheme.bodyMedium?.copyWith(
149
146
  color: cs.onSurface.withValues(alpha: 0.55),
150
147
  height: 1.4,
151
148
  ),
@@ -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,27 @@ 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 native-only (App Store / Play Store). In production the
25
+ // auto prompt never shows on web (nowhere to send the user). But a forced
26
+ // call — the admin preview — still shows the dialog on web so its design can
27
+ // be reviewed there; only the store action won't do anything.
28
+ if (kIsWeb && !force) {
29
+ return false;
30
+ }
21
31
  if (!context.mounted) {
22
32
  return false;
23
33
  }
@@ -41,97 +51,42 @@ Future<bool> showReviewDialog(
41
51
  barrierDismissible: false,
42
52
  builder: (dialogContext) {
43
53
  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
- },
54
+ final translations = Translations.of(dialogContext).review_popup;
55
+ return KasyDialog(
56
+ leadingIcon: KasyIcons.star,
57
+ iconTone: KasyDialogIconTone.info,
58
+ title: translations.title,
59
+ titleCentered: true,
60
+ message: translations.description,
61
+ onClose: () {
62
+ analytics.logEvent('rating_popup_close', {});
63
+ rating.delay();
64
+ Navigator.of(dialogContext).pop();
65
+ },
66
+ footer: Column(
67
+ crossAxisAlignment: CrossAxisAlignment.stretch,
68
+ children: [
69
+ KasyButton(
70
+ label: translations.rate_button,
71
+ expand: true,
72
+ onPressed: () {
73
+ analytics.logEvent('rating_popup_show', {});
74
+ ratingRepository.rate().then((_) => rating.review()).then(
75
+ (_) {
76
+ if (!dialogContext.mounted) return;
77
+ Navigator.of(dialogContext).pop();
78
+ },
79
+ );
80
+ },
81
+ ),
82
+ const SizedBox(height: KasySpacing.sm),
83
+ KasyButton(
84
+ label: translations.cancel_button,
85
+ variant: KasyButtonVariant.soft,
86
+ expand: true,
87
+ onPressed: () => Navigator.of(dialogContext).pop(true),
88
+ ),
89
+ ],
135
90
  ),
136
91
  );
137
92
  },
@@ -142,34 +97,3 @@ Future<bool> showReviewDialog(
142
97
  }
143
98
  return true;
144
99
  }
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
- }
@@ -72,6 +72,20 @@ class SharedPreferencesBuilder implements OnStartService {
72
72
  return prefs.getBool('biometric_enabled') ?? false;
73
73
  }
74
74
 
75
+ /// Whether the user has already been through the one-time onboarding intro.
76
+ ///
77
+ /// Persisted locally so onboarding shows only once per install. Crucially it
78
+ /// survives logout (logout clears the account identity, not the fact that the
79
+ /// user has already seen the intro), so after signing out the user lands on
80
+ /// the sign-in screen instead of being dragged through onboarding again.
81
+ Future<void> setOnboardingCompleted(bool completed) async {
82
+ await prefs.setBool('onboarding_completed', completed);
83
+ }
84
+
85
+ bool getOnboardingCompleted() {
86
+ return prefs.getBool('onboarding_completed') ?? false;
87
+ }
88
+
75
89
  /// How many times the user dismissed the ATT soft prompt without accepting.
76
90
  /// Used to back off and eventually stop asking. See [MaybeShowAttPermission].
77
91
  int getAttSoftDismissCount() {
@@ -0,0 +1,32 @@
1
+ import 'package:flutter/foundation.dart';
2
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+ import 'package:kasy_kit/core/app_update/app_update_repository.dart';
4
+ import 'package:kasy_kit/core/app_update/app_update_status.dart';
5
+ import 'package:kasy_kit/core/app_update/update_available_sheet.dart';
6
+ import 'package:kasy_kit/core/states/components/maybeshow_component.dart';
7
+ import 'package:kasy_kit/core/states/models/event_model.dart';
8
+
9
+ /// On app start, checks whether a newer version is available (remote config vs
10
+ /// installed version) and, if so, prompts the user to update via the store.
11
+ ///
12
+ /// Native-only. Distinct from `MaybeShowUpdateBottomSheet` (the "what's new"
13
+ /// sheet, which runs *after* the user already updated): this runs *before*,
14
+ /// when the user is behind. It is registered first so a required update
15
+ /// pre-empts every other start-up prompt.
16
+ class MaybeShowUpdateAvailable extends MaybeShowWithRef {
17
+ @override
18
+ Future<bool> handle(WidgetRef ref, AppEvent event) async {
19
+ if (event is! OnAppStartEvent) return false;
20
+ if (kIsWeb) return false;
21
+
22
+ final status = await ref.read(appUpdateRepositoryProvider).check();
23
+ if (status == AppUpdateStatus.upToDate) return false;
24
+ if (!ref.context.mounted) return false;
25
+
26
+ await showUpdateAvailableSheet(
27
+ ref.context,
28
+ forced: status == AppUpdateStatus.forced,
29
+ );
30
+ return true;
31
+ }
32
+ }
@@ -20,6 +20,10 @@ Future<void> confirmLogout(BuildContext context, WidgetRef ref) {
20
20
  message: tr.disconnect_confirm_message,
21
21
  cancelLabel: tr.disconnect_cancel,
22
22
  confirmLabel: tr.disconnect,
23
- onConfirm: () => ref.read(userStateNotifierProvider.notifier).onLogout(),
23
+ // Async so the confirm button shows a spinner while signing out, then the
24
+ // router redirect lands the user on the sign-in screen. (Don't fire-and-
25
+ // forget: that closed the dialog instantly and left the old screen frozen.)
26
+ onConfirmAsync: () =>
27
+ ref.read(userStateNotifierProvider.notifier).onLogout(),
24
28
  );
25
29
  }
@@ -10,6 +10,7 @@ import 'package:kasy_kit/core/data/repositories/user_repository.dart';
10
10
  import 'package:kasy_kit/core/initializer/onstart_service.dart';
11
11
  import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
12
12
  import 'package:kasy_kit/core/states/models/user_state.dart';
13
+ import 'package:kasy_kit/core/utils/image_bytes_loader.dart';
13
14
  import 'package:kasy_kit/environments.dart';
14
15
  import 'package:kasy_kit/features/authentication/repositories/authentication_repository.dart';
15
16
  import 'package:kasy_kit/features/notifications/providers/models/device.dart';
@@ -89,11 +90,18 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
89
90
  state = const UserState(user: User.loading());
90
91
  await _loadState();
91
92
  await _initDeviceRegistration();
93
+ // A successful sign-in puts the user past first-run onboarding. Remember it
94
+ // so that after a later logout they land on the sign-in screen instead of
95
+ // being sent back through onboarding.
96
+ await ref.read(sharedPreferencesProvider).setOnboardingCompleted(true);
92
97
  }
93
98
 
94
99
  /// Set the user as onboarded in the database
95
100
  /// This function is called when the user has completed the onboarding
96
101
  Future<void> onOnboarded() async {
102
+ // Remember locally that onboarding is done, so it's never shown again on
103
+ // this install (survives logout and account deletion).
104
+ await ref.read(sharedPreferencesProvider).setOnboardingCompleted(true);
97
105
  if (state.user.idOrNull == null) {
98
106
  // Guest with no account: persist onboarding in local state only.
99
107
  // No Firestore document exists yet — no write needed.
@@ -104,10 +112,27 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
104
112
  state = state.copyWith(user: newUser);
105
113
  }
106
114
 
115
+ /// Finish onboarding (or "continue as guest" from the sign-in screen) by
116
+ /// making sure the user has a backend identity, then marking them onboarded.
117
+ ///
118
+ /// This is the ONLY place an anonymous account is created. It is created
119
+ /// lazily here — never eagerly on app start, never on logout — so users who
120
+ /// never get this far (or who later sign out) don't leave orphan anonymous
121
+ /// accounts behind in the backend.
122
+ Future<void> continueAsGuest() async {
123
+ if (mode == AuthenticationMode.anonymous && state.user.idOrNull == null) {
124
+ await _loadAnonymousState();
125
+ }
126
+ await onOnboarded();
127
+ }
128
+
107
129
  /// Mark the user as onboarded immediately (optimistic) and write to the
108
130
  /// backend in background. Used by the skip-onboarding flow so navigation
109
131
  /// to home is instant — no spinner while waiting for a network round-trip.
110
132
  void onSkippedOnboarding() {
133
+ // Remember onboarding as done locally (same flag as [onOnboarded]) so it's
134
+ // never shown again, even after a logout.
135
+ unawaited(ref.read(sharedPreferencesProvider).setOnboardingCompleted(true));
111
136
  state = state.copyWith(
112
137
  user: switch (state.user) {
113
138
  final AuthenticatedUserData u => u.copyWith(onboarded: true),
@@ -142,11 +167,14 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
142
167
  await ref.read(sharedPreferencesProvider).setBiometricEnabled(false);
143
168
  // Forget the last bottom-bar tab so the next login lands on the default tab
144
169
  // (Home) instead of wherever the previous account left off.
145
- activeTabRouteNotifier.value = null;
170
+ forgetActiveTab();
146
171
  state = const UserState(user: User.anonymous());
147
- if (mode == AuthenticationMode.anonymous) {
148
- await _loadAnonymousState();
149
- }
172
+ // No anonymous re-signup here. The router redirect sends the user to the
173
+ // sign-in screen (onboarding is remembered as done, so it isn't repeated).
174
+ // Recreating an anonymous account on every logout would pile up orphan
175
+ // users in the backend — an anonymous account is only ever created lazily,
176
+ // in [continueAsGuest], when the user finishes onboarding or explicitly
177
+ // chooses to continue as a guest.
150
178
  }
151
179
 
152
180
  /// Refresh the user
@@ -164,6 +192,41 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
164
192
  state = state.copyWith(user: user ?? User.anonymous(id: id));
165
193
  }
166
194
 
195
+ /// On first social sign-in, copy the provider's profile photo (e.g. Google)
196
+ /// into our own storage so the user starts with an avatar. Best-effort and
197
+ /// one-shot: only runs when the user has no avatar yet, and it never overwrites
198
+ /// a photo the user set manually. Apple does not expose a photo (no-op there).
199
+ Future<void> _importSocialAvatarIfNeeded() async {
200
+ final user = state.user;
201
+ if (user is! AuthenticatedUserData) return;
202
+ final userId = user.id;
203
+ if (userId == null || userId.isEmpty) return;
204
+ if (user.avatarPath?.isNotEmpty ?? false) return; // keep existing avatar
205
+ try {
206
+ final photoUrl = await _authenticationRepository.getCurrentUserPhotoUrl();
207
+ if (photoUrl == null || photoUrl.isEmpty) return;
208
+ final bytes = await loadImageBytes(_normalizeAvatarUrl(photoUrl));
209
+ if (bytes == null || bytes.isEmpty) return;
210
+ await for (final _ in _userRepository.saveAvatar(
211
+ userId: userId,
212
+ data: Uint8List.fromList(bytes),
213
+ )) {}
214
+ await refresh();
215
+ } catch (e) {
216
+ // Best-effort: keep the fallback avatar if the import fails.
217
+ _logger.w('Social avatar import skipped: $e');
218
+ }
219
+ }
220
+
221
+ /// Google serves profile photos with a size suffix (e.g. `=s96-c`, only 96px).
222
+ /// Request a ~400px source so the avatar stays crisp after our 450px
223
+ /// re-compression. URLs from other providers are returned unchanged.
224
+ String _normalizeAvatarUrl(String url) {
225
+ if (!url.contains('googleusercontent.com')) return url;
226
+ final base = url.contains('=') ? url.substring(0, url.indexOf('=')) : url;
227
+ return '$base=s400-c';
228
+ }
229
+
167
230
  /// This function is called after a user successfuly purchased a subscription
168
231
  /// It will refresh the subscription state without waiting for the webhook
169
232
  /// (which can take some time and could show a wrong state to the user)
@@ -208,11 +271,14 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
208
271
  }
209
272
  await _userRepository.delete();
210
273
  await _authenticationRepository.logout();
274
+ // Same as onLogout: forget the last bottom-bar tab so the next account that
275
+ // signs in lands on Home, not wherever the deleted account left off (the
276
+ // user deletes from Settings, so without this the next login reopens it).
277
+ forgetActiveTab();
211
278
  state = const UserState(user: User.anonymous());
212
-
213
- if (mode == AuthenticationMode.anonymous) {
214
- await _loadAnonymousState();
215
- }
279
+ // Same as [onLogout]: no anonymous re-signup. The user lands on the sign-in
280
+ // screen; a new anonymous account is only created lazily if they choose to
281
+ // continue as a guest.
216
282
  }
217
283
 
218
284
  // -------------------------------
@@ -252,13 +318,16 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
252
318
  Future<void> _loadState() async {
253
319
  final credentials = await _authenticationRepository.get();
254
320
 
255
- if (credentials == null && mode == AuthenticationMode.anonymous) {
256
- _logger.i('Anonymous user mode activated, signup anonymously');
257
- await _loadAnonymousState();
258
- } else if (credentials == null && mode == AuthenticationMode.authRequired) {
259
- _logger.i('Authentification required, user is not connected');
321
+ if (credentials == null) {
322
+ // No account yet. We deliberately do NOT create an anonymous account here
323
+ // anymore (even in anonymous mode). It's created lazily in
324
+ // [continueAsGuest] when the user finishes onboarding or taps "continue
325
+ // as guest", so simply opening the app — or signing out — never piles up
326
+ // orphan anonymous users. The router redirect decides where to send them:
327
+ // onboarding on the very first run, the sign-in screen after a logout.
328
+ _logger.i('No credentials: user starts as a guest with no account yet');
260
329
  state = state.copyWith(user: const User.anonymous());
261
- } else if (credentials != null) {
330
+ } else {
262
331
  _logger.i('User is connected with id ${credentials.id}');
263
332
  var user = await _userRepository.get(credentials.id);
264
333
  // Retry a few times: the Cloud Function onUserRegistration may not have
@@ -299,10 +368,12 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
299
368
  );
300
369
  final syncedUser = await _userRepository.get(credentials.id);
301
370
  state = state.copyWith(user: syncedUser ?? user);
371
+ unawaited(_importSocialAvatarIfNeeded());
302
372
  return;
303
373
  }
304
374
  }
305
375
  state = state.copyWith(user: user);
376
+ unawaited(_importSocialAvatarIfNeeded());
306
377
  }
307
378
  }
308
379