kasy-cli 1.13.0 → 1.14.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 (120) hide show
  1. package/bin/kasy.js +122 -7
  2. package/lib/commands/add.js +2 -2
  3. package/lib/commands/codemagic.js +11 -4
  4. package/lib/commands/deploy.js +3 -3
  5. package/lib/commands/favicon.js +115 -0
  6. package/lib/commands/icon.js +143 -0
  7. package/lib/commands/ios.js +20 -5
  8. package/lib/commands/new.js +8 -20
  9. package/lib/commands/remove.js +1 -1
  10. package/lib/commands/reset.js +287 -0
  11. package/lib/commands/run.js +24 -17
  12. package/lib/commands/splash.js +3 -4
  13. package/lib/commands/update.js +1 -1
  14. package/lib/scaffold/backends/api/patch/README.md +1 -1
  15. package/lib/scaffold/backends/api/patch/android/app/src/main/AndroidManifest.xml +1 -1
  16. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +53 -0
  17. package/lib/scaffold/backends/api/pubspec.yaml.tpl +11 -1
  18. package/lib/scaffold/backends/firebase/tokens.js +2 -2
  19. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -2
  20. package/lib/scaffold/backends/supabase/migrations/20240101000011_dedupe_device_tokens.sql +34 -0
  21. package/lib/scaffold/backends/supabase/patch/README.md +1 -1
  22. package/lib/scaffold/backends/supabase/patch/android/app/src/main/AndroidManifest.xml +1 -1
  23. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +43 -0
  24. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +11 -1
  25. package/lib/utils/apple-release.js +85 -16
  26. package/lib/utils/checks.js +4 -105
  27. package/lib/utils/flutter-run.js +173 -0
  28. package/lib/utils/i18n.js +335 -0
  29. package/lib/utils/mobile-identity.js +35 -0
  30. package/lib/utils/ui.js +114 -0
  31. package/package.json +1 -2
  32. package/templates/firebase/README.en.md +1 -1
  33. package/templates/firebase/README.es.md +1 -1
  34. package/templates/firebase/README.md +1 -1
  35. package/templates/firebase/android/app/build.gradle.kts +10 -1
  36. package/templates/firebase/android/app/src/main/AndroidManifest.xml +1 -1
  37. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +25 -1
  38. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +160 -11
  39. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +15 -0
  40. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +9 -0
  41. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +12 -0
  42. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +5 -0
  43. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +17 -0
  44. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +5 -0
  45. package/templates/firebase/android/app/src/main/res/layout/widget_loading.xml +8 -0
  46. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +46 -0
  47. package/templates/firebase/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  48. package/templates/firebase/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  49. package/templates/firebase/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  50. package/templates/firebase/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  51. package/templates/firebase/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/xml/mywidget_info.xml +9 -3
  53. package/templates/firebase/assets/images/favicon.png +0 -0
  54. package/templates/firebase/assets/images/icon.png +0 -0
  55. package/templates/firebase/firestore.indexes.json +10 -0
  56. package/templates/firebase/functions/src/core/data/entities/user_device_entity.ts +3 -0
  57. package/templates/firebase/functions/src/core/data/repositories/user_device_repository.ts +17 -1
  58. package/templates/firebase/functions/src/index.ts +1 -0
  59. package/templates/firebase/functions/src/notifications/device_triggers.ts +58 -0
  60. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +116 -33
  61. package/templates/firebase/ios/Runner/AppDelegate.swift +17 -1
  62. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
  63. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
  64. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
  65. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
  66. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
  67. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
  68. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
  69. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
  70. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
  71. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
  72. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png +0 -0
  73. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png +0 -0
  74. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png +0 -0
  75. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png +0 -0
  76. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
  77. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
  78. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png +0 -0
  79. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png +0 -0
  80. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
  81. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
  82. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
  83. package/templates/firebase/ios/Runner/Info.plist +2 -2
  84. package/templates/firebase/ios/Runner/es.lproj/InfoPlist.strings +1 -1
  85. package/templates/firebase/ios/Runner/pt-BR.lproj/InfoPlist.strings +1 -1
  86. package/templates/firebase/ios/Runner/pt.lproj/InfoPlist.strings +1 -1
  87. package/templates/firebase/lib/components/kasy_button.dart +8 -0
  88. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +18 -0
  89. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +67 -19
  90. package/templates/firebase/lib/core/home_widgets/home_widget_service.dart +22 -8
  91. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +16 -4
  92. package/templates/firebase/lib/core/states/components/maybeshow_component.dart +4 -8
  93. package/templates/firebase/lib/core/states/user_state_notifier.dart +13 -1
  94. package/templates/firebase/lib/features/home/home_page.dart +0 -6
  95. package/templates/firebase/lib/features/notifications/api/device_api.dart +57 -0
  96. package/templates/firebase/lib/features/notifications/providers/models/notification.dart +11 -1
  97. package/templates/firebase/lib/features/notifications/repositories/device_repository.dart +9 -0
  98. package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +1 -4
  99. package/templates/firebase/lib/features/notifications/shared/att_permission.dart +28 -8
  100. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -1
  101. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +44 -11
  102. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +31 -29
  103. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +21 -5
  104. package/templates/firebase/lib/i18n/en.i18n.json +4 -1
  105. package/templates/firebase/lib/i18n/es.i18n.json +4 -1
  106. package/templates/firebase/lib/i18n/pt.i18n.json +4 -1
  107. package/templates/firebase/pubspec.yaml +6 -1
  108. package/templates/firebase/test/features/notifications/data/device_api_fake.dart +9 -0
  109. package/templates/firebase/web/favicon.png +0 -0
  110. package/templates/firebase/web/icons/Icon-192.png +0 -0
  111. package/templates/firebase/web/icons/Icon-512.png +0 -0
  112. package/templates/firebase/web/icons/Icon-maskable-192.png +0 -0
  113. package/templates/firebase/web/icons/Icon-maskable-512.png +0 -0
  114. package/templates/firebase/web/index.html +3 -0
  115. package/templates/firebase/web/manifest.json +3 -3
  116. package/templates/firebase/assets/images/app_icon.png +0 -0
  117. package/templates/firebase/assets/images/onboarding/img1.jpg +0 -0
  118. package/templates/firebase/assets/images/onboarding/onboarding.png +0 -0
  119. package/templates/firebase/lib/core/states/components/maybe_ask_biometric_setup.dart +0 -88
  120. package/templates/firebase/lib/features/notifications/shared/notification_permission_bottom_sheet.dart +0 -144
@@ -1,3 +1,5 @@
1
+ import 'dart:async';
2
+
1
3
  import 'package:background_fetch/background_fetch.dart';
2
4
  import 'package:flutter/foundation.dart';
3
5
  import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -8,14 +10,7 @@ import 'package:kasy_kit/core/initializer/onstart_service.dart';
8
10
  import 'package:logger/logger.dart';
9
11
 
10
12
  final homeWidgetsManagerProvider = Provider<HomeWidgetsManager>(
11
- (ref) {
12
- // Force-build the widget service at app startup so its user-state
13
- // listener attaches and the widget auto-refreshes when subscription
14
- // status or other relevant fields change. Without this read, the
15
- // listener would only attach on first manual update.
16
- ref.read(myWidgetHomeWidgetProvider.notifier);
17
- return HomeWidgetsManager();
18
- },
13
+ (ref) => HomeWidgetsManager(ref),
19
14
  );
20
15
 
21
16
  const String appGroupId = 'group.com.aicrus.firebase.kit';
@@ -26,12 +21,31 @@ const String appGroupId = 'group.com.aicrus.firebase.kit';
26
21
  /// will be used to initialize the home widgets and set the app group id
27
22
  /// Register the background task for the home widgets
28
23
  class HomeWidgetsManager implements OnStartService {
24
+ HomeWidgetsManager(this._ref);
25
+
26
+ final Ref _ref;
27
+
29
28
  @override
30
29
  Future<void> init() async {
31
30
  if (kIsWeb) return;
32
31
  try {
32
+ // Must be set BEFORE any saveWidgetData call, otherwise the data lands
33
+ // in the default UserDefaults suite and the native widget extension
34
+ // (which reads from the app group) sees nothing.
33
35
  await HomeWidget.setAppGroupId(appGroupId);
34
36
 
37
+ // Read the widget notifier so its user-state listener attaches —
38
+ // future state changes (login/logout, subscription) auto-refresh
39
+ // the widget without waiting for the 15-min background tick.
40
+ final myWidget = _ref.read(myWidgetHomeWidgetProvider.notifier);
41
+ // Push initial data so the widget renders something on first install
42
+ // instead of staying blank until the background task fires.
43
+ // Fire-and-forget: we do NOT await here because update() may do a
44
+ // network call (RevenueCat) that could stall app startup and even
45
+ // prevent BackgroundFetch from being configured. setAppGroupId has
46
+ // already completed, so it is safe to fire it off now.
47
+ unawaited(myWidget.update());
48
+
35
49
  final status = await BackgroundFetch.configure(
36
50
  BackgroundFetchConfig(
37
51
  minimumFetchInterval: 15,
@@ -60,11 +60,23 @@ class SharedPreferencesBuilder implements OnStartService {
60
60
  return prefs.getBool('biometric_enabled') ?? false;
61
61
  }
62
62
 
63
- Future<void> setBiometricPromptShown(bool shown) async {
64
- await prefs.setBool('biometric_prompt_shown', shown);
63
+ /// How many times the user dismissed the ATT soft prompt without accepting.
64
+ /// Used to back off and eventually stop asking. See [MaybeShowAttPermission].
65
+ int getAttSoftDismissCount() {
66
+ return prefs.getInt('att_soft_dismiss_count') ?? 0;
65
67
  }
66
68
 
67
- bool getBiometricPromptShown() {
68
- return prefs.getBool('biometric_prompt_shown') ?? false;
69
+ Future<void> setAttSoftDismissCount(int count) async {
70
+ await prefs.setInt('att_soft_dismiss_count', count);
71
+ }
72
+
73
+ DateTime? getAttSoftLastAskedAt() {
74
+ final millis = prefs.getInt('att_soft_last_asked_at');
75
+ if (millis == null) return null;
76
+ return DateTime.fromMillisecondsSinceEpoch(millis);
77
+ }
78
+
79
+ Future<void> setAttSoftLastAskedAt(DateTime when) async {
80
+ await prefs.setInt('att_soft_last_asked_at', when.millisecondsSinceEpoch);
69
81
  }
70
82
  }
@@ -13,20 +13,16 @@ import 'package:kasy_kit/core/states/models/event_model.dart';
13
13
  /// Ex of usage:
14
14
  /// @override
15
15
  /// Widget build(BuildContext context) {
16
- /// final homeState = ref.watch(homeNotifierProvider);
17
- /// final userState = ref.watch(userStateNotifierProvider);
18
- /// // final translations = ref.watch(translationsProvider);
19
- ///
20
16
  /// return ConditionalWidgetsEvents(
21
17
  /// eventWidgets: [
22
18
  /// MaybeShowPremiumPage(),
23
- /// MaybeShowNotificationPermission(),
24
- /// MaybeLevelUpBottomSheet(),
19
+ /// MaybeShowAttPermission(),
25
20
  /// MaybeAskForReview(),
26
21
  /// MaybeAskForRating(),
27
22
  /// ],
28
- /// child: Background.blue(
29
- /// child: SafeArea(...
23
+ /// child: ...,
24
+ /// );
25
+ /// }
30
26
  /// A widget that can be shown or not based on a condition.
31
27
  sealed class MaybeShow {}
32
28
 
@@ -6,6 +6,7 @@ import 'package:kasy_kit/core/data/models/subscription.dart';
6
6
  import 'package:kasy_kit/core/data/models/user.dart';
7
7
  import 'package:kasy_kit/core/data/repositories/user_repository.dart';
8
8
  import 'package:kasy_kit/core/initializer/onstart_service.dart';
9
+ import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
9
10
  import 'package:kasy_kit/core/states/models/user_state.dart';
10
11
  import 'package:kasy_kit/environnements.dart';
11
12
  import 'package:kasy_kit/features/authentication/repositories/authentication_repository.dart';
@@ -125,8 +126,19 @@ class UserStateNotifier extends _$UserStateNotifier implements OnStartService {
125
126
  Future<void> onLogout() async {
126
127
  final userId = state.user.idOrThrow;
127
128
  _deviceRepository.removeTokenUpdateListener();
128
- await _deviceRepository.unregister(userId);
129
+ // Best-effort: if the network call fails we still proceed with logout so
130
+ // the user is never stuck on the previous account. A stale device doc on
131
+ // the old user is cleaned up server-side by the cross-user token dedup
132
+ // trigger when the same install registers under a new account.
133
+ try {
134
+ await _deviceRepository.unregister(userId);
135
+ } catch (e) {
136
+ _logger.w('Failed to unregister device during logout: $e');
137
+ }
129
138
  await _authenticationRepository.logout();
139
+ // Biometric lock is a per-account preference, not a device-wide one.
140
+ // The next user signing in on this install should start without it set.
141
+ await ref.read(sharedPreferencesProvider).setBiometricEnabled(false);
130
142
  state = const UserState(user: User.anonymous());
131
143
  if (mode == AuthenticationMode.anonymous) {
132
144
  await _loadAnonymousState();
@@ -4,7 +4,6 @@ import 'package:kasy_kit/components/kasy_app_bar.dart';
4
4
  import 'package:kasy_kit/core/bottom_menu/bart_inner_paths.dart';
5
5
  import 'package:kasy_kit/core/bottom_menu/kasy_bart_navigation.dart';
6
6
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
7
- import 'package:kasy_kit/core/states/components/maybe_ask_biometric_setup.dart';
8
7
  import 'package:kasy_kit/core/states/components/maybe_ask_rating.dart';
9
8
  import 'package:kasy_kit/core/states/components/maybe_show_update_bottom_sheet.dart';
10
9
  import 'package:kasy_kit/core/states/components/maybeshow_component.dart';
@@ -12,7 +11,6 @@ import 'package:kasy_kit/core/theme/theme.dart';
12
11
  import 'package:kasy_kit/features/home/home_components_page.dart';
13
12
  import 'package:kasy_kit/features/home/home_features_page.dart';
14
13
  import 'package:kasy_kit/features/notifications/shared/att_permission.dart';
15
- import 'package:kasy_kit/features/notifications/shared/notification_permission_bottom_sheet.dart';
16
14
  import 'package:kasy_kit/features/subscription/shared/maybeshow_premium.dart';
17
15
  import 'package:kasy_kit/i18n/translations.g.dart';
18
16
 
@@ -27,13 +25,9 @@ class HomePage extends ConsumerWidget {
27
25
  eventWidgets: [
28
26
  MaybeAskForReview(),
29
27
  MaybeAskForRating(),
30
- // First OnAppStart handler that may return true — premium / notifications
31
- // no longer suppress the biometric one-shot prompt.
32
- MaybeAskBiometricSetup(),
33
28
  MaybeShowPremiumPage(),
34
29
  MaybeShowUpdateBottomSheet(),
35
30
  MaybeShowAttPermission(),
36
- MaybeShowNotificationPermission(),
37
31
  ],
38
32
  child: ColoredBox(
39
33
  color: context.colors.background,
@@ -38,6 +38,15 @@ abstract class DeviceApi {
38
38
  /// Unregister the device in the backend
39
39
  Future<void> unregister(String userId, String deviceId);
40
40
 
41
+ /// Heartbeat — update the `lastUpdateDate` on the current device doc.
42
+ /// Used so the backend can detect orphaned device docs from previous installs.
43
+ Future<void> touch(String userId, String installationId);
44
+
45
+ /// Delete device docs of the same user that haven't been touched in a while.
46
+ /// Called after registering a fresh installation to remove orphans left by
47
+ /// previous installs (whose installationId no longer matches).
48
+ Future<void> cleanupStaleDevices(String userId, String currentInstallationId);
49
+
41
50
  /// Listen to token refresh
42
51
  void onTokenRefresh(OnTokenRefresh onTokenRefresh);
43
52
 
@@ -167,6 +176,54 @@ class FirebaseDeviceApi implements DeviceApi {
167
176
  }
168
177
  }
169
178
 
179
+ @override
180
+ Future<void> touch(String userId, String installationId) async {
181
+ try {
182
+ await retryOnFirestoreUnavailable(
183
+ () => _client
184
+ .collection('users')
185
+ .doc(userId)
186
+ .collection('devices')
187
+ .doc(installationId)
188
+ .update({'lastUpdateDate': Timestamp.now()}),
189
+ );
190
+ } catch (_) {
191
+ // Missing doc — happens if the device was unregistered or never saved.
192
+ // Caller (DeviceRepository) recovers by re-registering on next session.
193
+ }
194
+ }
195
+
196
+ @override
197
+ Future<void> cleanupStaleDevices(
198
+ String userId,
199
+ String currentInstallationId,
200
+ ) async {
201
+ // Devices not touched in the last 30 days are treated as orphans from
202
+ // previous installations on the same physical device. Real second devices
203
+ // that the user actively uses stay above this threshold via heartbeat.
204
+ final cutoff = DateTime.now().subtract(const Duration(days: 30));
205
+ try {
206
+ final snapshot = await _client
207
+ .collection('users')
208
+ .doc(userId)
209
+ .collection('devices')
210
+ .where('lastUpdateDate', isLessThan: Timestamp.fromDate(cutoff))
211
+ .get();
212
+ final batch = _client.batch();
213
+ var hasDeletions = false;
214
+ for (final doc in snapshot.docs) {
215
+ if (doc.id == currentInstallationId) continue;
216
+ batch.delete(doc.reference);
217
+ hasDeletions = true;
218
+ }
219
+ if (hasDeletions) {
220
+ await batch.commit();
221
+ }
222
+ } catch (e) {
223
+ Logger().w('cleanupStaleDevices failed: $e');
224
+ }
225
+ }
226
+
170
227
  @override
171
228
  void onTokenRefresh(OnTokenRefresh onTokenRefresh) {
172
229
  _onTokenRefreshSubscription =
@@ -117,6 +117,8 @@ sealed class NotificationPermission {
117
117
  await permission.ask();
118
118
  case NotificationPermissionDenied():
119
119
  await permission.ask();
120
+ case NotificationPermissionPermanentlyDenied():
121
+ await permission.openSettings();
120
122
  case NotificationPermissionGranted():
121
123
  await permission.ensureSetup();
122
124
  }
@@ -151,7 +153,7 @@ class NotificationPermissionGranted extends NotificationPermission {
151
153
  }
152
154
  }
153
155
 
154
- /// we asked for permission and it was denied
156
+ /// we asked for permission and it was denied (but can still be asked again)
155
157
  class NotificationPermissionDenied extends NotificationPermission {
156
158
  final NotificationSettings? _notificationSettings;
157
159
  final NotificationsRepository? _repository;
@@ -175,6 +177,14 @@ class NotificationPermissionDenied extends NotificationPermission {
175
177
  }
176
178
  }
177
179
 
180
+ /// User denied the permission and the OS will not show the native prompt again.
181
+ /// The only way back is the system settings of the app.
182
+ class NotificationPermissionPermanentlyDenied extends NotificationPermission {
183
+ Future<void> openSettings() async {
184
+ await openAppSettings();
185
+ }
186
+ }
187
+
178
188
  /// we never asked for permission
179
189
  class NotificationPermissionWaiting extends NotificationPermission {
180
190
  final NotificationSettings? _notificationSettings;
@@ -54,6 +54,9 @@ class DeviceRepository {
54
54
  // Without the userId check, a new anonymous user (same device token) would
55
55
  // reuse the cached prefs and never register in the database.
56
56
  if (device != null && device.token == newDevice.token && cachedUserId == userId) {
57
+ // Heartbeat — keeps the backend's stale-device filter (60d TTL) from
58
+ // dropping this active install. Cheap: one Firestore update per launch.
59
+ await _deviceApi.touch(userId, newDevice.installationId);
57
60
  return;
58
61
  }
59
62
  // Include device properties so the welcome notification fires in the correct locale.
@@ -62,6 +65,12 @@ class DeviceRepository {
62
65
  final response = await _deviceApi.register(userId, deviceWithLocale);
63
66
  await _saveInPrefs(response);
64
67
  await _prefs.setString(_deviceUserIdPrefsKey, userId);
68
+ // Remove orphan devices left by previous installs on the same physical
69
+ // device (different installationId, never touched again). Without this,
70
+ // a re-install (Xcode -> TestFlight, or TestFlight build update with an
71
+ // uninstall in between) leaves stale tokens that still receive push,
72
+ // causing duplicated notifications.
73
+ await _deviceApi.cleanupStaleDevices(userId, newDevice.installationId);
65
74
  }
66
75
 
67
76
  Future<void> unregister(String userId) async {
@@ -158,10 +158,7 @@ class AppNotificationsRepository implements NotificationsRepository {
158
158
  repository: this,
159
159
  );
160
160
  case PermissionStatus.permanentlyDenied:
161
- return NotificationPermissionDenied(
162
- notificationSettings: _notificationSettings,
163
- repository: this,
164
- );
161
+ return NotificationPermissionPermanentlyDenied();
165
162
  default:
166
163
  return NotificationPermissionWaiting(
167
164
  notificationSettings: _notificationSettings,
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
4
4
  import 'package:kasy_kit/components/components.dart';
5
5
  import 'package:kasy_kit/core/data/api/tracking_api.dart';
6
6
  import 'package:kasy_kit/core/icons/kasy_icons.dart';
7
+ import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
7
8
  import 'package:kasy_kit/core/states/components/maybeshow_component.dart';
8
9
  import 'package:kasy_kit/core/states/models/event_model.dart';
9
10
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
@@ -13,15 +14,19 @@ import 'package:permission_handler/permission_handler.dart';
13
14
 
14
15
  /// Requests App Tracking Transparency (ATT) on iOS if not yet determined.
15
16
  ///
16
- /// This handler always returns `false` so the event chain continues and
17
- /// [MaybeShowNotificationPermission] can still run in the same app-start cycle.
18
- ///
19
- /// Placement: add it to [ConditionalWidgetsEvents] immediately before the
20
- /// notification-permission handler.
17
+ /// Apple only allows the native ATT prompt to be shown once per install, so we
18
+ /// gate the system call behind a soft explanation dialog (KasyDialog) first.
19
+ /// If the user dismisses the soft dialog, we back off and ask again later (up
20
+ /// to [_maxSoftAttempts] times, with [_cooldown] between attempts). After the
21
+ /// last attempt we stop asking forever.
21
22
  ///
22
23
  /// To remove: delete this file and remove [MaybeShowAttPermission] from
23
24
  /// [HomePage]'s event list.
24
25
  class MaybeShowAttPermission implements MaybeShowWithRef {
26
+ static const Duration _settleDelay = Duration(milliseconds: 1500);
27
+ static const int _maxSoftAttempts = 3;
28
+ static const Duration _cooldown = Duration(days: 7);
29
+
25
30
  @override
26
31
  Future<bool> handle(WidgetRef ref, AppEvent event) async {
27
32
  if (kIsWeb) return false;
@@ -32,12 +37,28 @@ class MaybeShowAttPermission implements MaybeShowWithRef {
32
37
  // `denied` in permission_handler maps to iOS "notDetermined" — never asked.
33
38
  if (!status.isDenied) return false;
34
39
 
40
+ final prefs = ref.read(sharedPreferencesProvider);
41
+ final attempts = prefs.getAttSoftDismissCount();
42
+ if (attempts >= _maxSoftAttempts) return false;
43
+
44
+ final lastAsked = prefs.getAttSoftLastAskedAt();
45
+ if (lastAsked != null &&
46
+ DateTime.now().difference(lastAsked) < _cooldown) {
47
+ return false;
48
+ }
49
+
50
+ await Future<void>.delayed(_settleDelay);
51
+ await prefs.setAttSoftLastAskedAt(DateTime.now());
35
52
  if (!ref.context.mounted) return false;
36
53
 
37
54
  final confirmed = await _showExplanationDialog(ref.context);
38
- if (!confirmed) return false;
55
+ if (!confirmed) {
56
+ await prefs.setAttSoftDismissCount(attempts + 1);
57
+ return false;
58
+ }
39
59
 
40
60
  await Permission.appTrackingTransparency.request();
61
+ await prefs.setAttSoftDismissCount(_maxSoftAttempts);
41
62
 
42
63
  final userId = ref.read(userStateNotifierProvider).user.idOrNull;
43
64
  if (userId != null) {
@@ -45,8 +66,7 @@ class MaybeShowAttPermission implements MaybeShowWithRef {
45
66
  ref.read(facebookEventApiProvider).initUser(userId).ignore();
46
67
  }
47
68
 
48
- // Return false so MaybeShowNotificationPermission also runs.
49
- return false;
69
+ return true;
50
70
  }
51
71
 
52
72
  Future<bool> _showExplanationDialog(BuildContext context) async {
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
4
4
  import 'package:flutter/rendering.dart';
5
5
  import 'package:flutter_riverpod/flutter_riverpod.dart';
6
6
  import 'package:kasy_kit/components/components.dart';
7
+ import 'package:kasy_kit/core/bottom_menu/kasy_bart_navigation.dart';
7
8
  import 'package:kasy_kit/core/theme/theme.dart';
8
9
  import 'package:kasy_kit/features/notifications/providers/models/notification.dart'
9
10
  as app;
@@ -20,25 +21,39 @@ class NotificationsPage extends ConsumerStatefulWidget {
20
21
  ConsumerState<NotificationsPage> createState() => _NotificationsPageState();
21
22
  }
22
23
 
23
- class _NotificationsPageState extends ConsumerState<NotificationsPage> {
24
+ class _NotificationsPageState extends ConsumerState<NotificationsPage>
25
+ with WidgetsBindingObserver {
24
26
  final ScrollController _scrollController = ScrollController();
25
27
  Timer? _autoReadTimer;
26
28
 
27
29
  @override
28
30
  void initState() {
29
31
  super.initState();
32
+ WidgetsBinding.instance.addObserver(this);
30
33
  _scrollController.addListener(_onScrollChange);
31
34
  requestReadAll();
32
35
  }
33
36
 
34
37
  @override
35
38
  void dispose() {
39
+ WidgetsBinding.instance.removeObserver(this);
36
40
  _autoReadTimer?.cancel();
37
41
  _scrollController.removeListener(_onScrollChange);
38
42
  _scrollController.dispose();
39
43
  super.dispose();
40
44
  }
41
45
 
46
+ @override
47
+ void didChangeAppLifecycleState(AppLifecycleState state) {
48
+ super.didChangeAppLifecycleState(state);
49
+ if (state == AppLifecycleState.resumed && mounted) {
50
+ // Native permission dialogs push the app to the background briefly. When
51
+ // we come back, the Bart bottom bar can stay hidden because its internal
52
+ // visibility state is reset. Force it back so the user is never stranded.
53
+ kasyShowBottomBar(context);
54
+ }
55
+ }
56
+
42
57
  void _onScrollChange() {
43
58
  final direction = _scrollController.position.userScrollDirection;
44
59
  final isScrollingDown = direction == ScrollDirection.reverse;
@@ -1,16 +1,45 @@
1
1
  import 'package:flutter/material.dart';
2
2
  import 'package:flutter_riverpod/flutter_riverpod.dart';
3
3
  import 'package:kasy_kit/components/components.dart';
4
+ import 'package:kasy_kit/core/bottom_menu/kasy_bart_navigation.dart';
4
5
  import 'package:kasy_kit/core/theme/theme.dart';
5
6
  import 'package:kasy_kit/features/notifications/providers/models/notification.dart';
6
7
  import 'package:kasy_kit/features/notifications/repositories/notifications_repository.dart';
7
8
  import 'package:kasy_kit/i18n/translations.g.dart';
8
9
 
9
- class EmptyNotifications extends ConsumerWidget {
10
+ class EmptyNotifications extends ConsumerStatefulWidget {
10
11
  const EmptyNotifications({super.key});
11
12
 
12
13
  @override
13
- Widget build(BuildContext context, WidgetRef ref) {
14
+ ConsumerState<EmptyNotifications> createState() => _EmptyNotificationsState();
15
+ }
16
+
17
+ class _EmptyNotificationsState extends ConsumerState<EmptyNotifications> {
18
+ late Future<NotificationPermission> _permissionFuture;
19
+
20
+ @override
21
+ void initState() {
22
+ super.initState();
23
+ _permissionFuture = _loadPermission();
24
+ }
25
+
26
+ Future<NotificationPermission> _loadPermission() {
27
+ return ref.read(notificationRepositoryProvider).getPermissionStatus();
28
+ }
29
+
30
+ Future<void> _onAskPressed(NotificationPermission permission) async {
31
+ await permission.maybeAsk();
32
+ if (!mounted) return;
33
+ // The native permission dialog pulls the app out of focus; restore the
34
+ // bottom bar defensively before refreshing the local state.
35
+ kasyShowBottomBar(context);
36
+ setState(() {
37
+ _permissionFuture = _loadPermission();
38
+ });
39
+ }
40
+
41
+ @override
42
+ Widget build(BuildContext context) {
14
43
  final tr = context.t.notifications;
15
44
  return Padding(
16
45
  padding: const EdgeInsets.symmetric(horizontal: KasySpacing.md),
@@ -50,18 +79,22 @@ class EmptyNotifications extends ConsumerWidget {
50
79
  ),
51
80
  const SizedBox(height: KasySpacing.xl),
52
81
  FutureBuilder<NotificationPermission>(
53
- future: ref
54
- .read(notificationRepositoryProvider)
55
- .getPermissionStatus(),
82
+ future: _permissionFuture,
56
83
  builder: (context, snapshot) {
57
- final isGranted = snapshot.data is NotificationPermissionGranted;
58
- if (isGranted) return const SizedBox.shrink();
84
+ final permission = snapshot.data;
85
+ if (permission == null) return const SizedBox.shrink();
86
+ if (permission is NotificationPermissionGranted) {
87
+ return const SizedBox.shrink();
88
+ }
89
+ final isLocked =
90
+ permission is NotificationPermissionPermanentlyDenied;
59
91
  return KasyButton(
60
- label: tr.empty_cta,
92
+ label: isLocked ? tr.empty_cta_open_settings : tr.empty_cta,
61
93
  variant: KasyButtonVariant.soft,
62
- icon: KasyIcons.notificationAdd,
63
- onPressed: () =>
64
- snapshot.data?.maybeAsk(),
94
+ icon: isLocked
95
+ ? KasyIcons.settings
96
+ : KasyIcons.notificationAdd,
97
+ onPressed: () => _onAskPressed(permission),
65
98
  );
66
99
  },
67
100
  ),
@@ -11,7 +11,7 @@ import 'package:kasy_kit/core/states/user_state_notifier.dart';
11
11
  import 'package:kasy_kit/core/toast/toast_service.dart';
12
12
  import 'package:kasy_kit/core/web_device_preview/web_device_preview.dart';
13
13
  import 'package:kasy_kit/core/widgets/update_bottom_sheet.dart';
14
- import 'package:kasy_kit/features/notifications/shared/notification_permission_bottom_sheet.dart';
14
+ import 'package:kasy_kit/features/notifications/api/local_notifier.dart';
15
15
  import 'package:kasy_kit/features/settings/settings_page.dart';
16
16
  import 'package:kasy_kit/features/settings/ui/components/admin/admin_routes.dart';
17
17
  import 'package:kasy_kit/features/settings/ui/widgets/settings_tile.dart';
@@ -89,30 +89,33 @@ class _AdminSheet extends ConsumerWidget {
89
89
  );
90
90
  },
91
91
  ),
92
- const SettingsDivider(),
93
- ValueListenableBuilder<bool>(
94
- valueListenable: webDevicePreviewEnabledNotifier,
95
- builder: (context, enabled, _) {
96
- return SettingsSwitchTile(
97
- icon: KasyIcons.phoneAndroid,
98
- title: t.settings.admin.device_preview_title,
99
- value: enabled,
100
- onChanged: (v) async {
101
- final navigator = Navigator.of(
102
- context,
103
- rootNavigator: true,
104
- );
105
- final p = await SharedPreferences.getInstance();
106
- await p.setBool(
107
- webDevicePreviewEnabledPrefKey,
108
- v,
109
- );
110
- webDevicePreviewEnabledNotifier.value = v;
111
- if (v) navigator.pop();
112
- },
113
- );
114
- },
115
- ),
92
+ if (kIsWeb) ...[
93
+ const SettingsDivider(),
94
+ ValueListenableBuilder<bool>(
95
+ valueListenable: webDevicePreviewEnabledNotifier,
96
+ builder: (context, enabled, _) {
97
+ return SettingsSwitchTile(
98
+ icon: KasyIcons.phoneAndroid,
99
+ title: t.settings.admin.device_preview_title,
100
+ value: enabled,
101
+ onChanged: (v) async {
102
+ final navigator = Navigator.of(
103
+ context,
104
+ rootNavigator: true,
105
+ );
106
+ final p =
107
+ await SharedPreferences.getInstance();
108
+ await p.setBool(
109
+ webDevicePreviewEnabledPrefKey,
110
+ v,
111
+ );
112
+ webDevicePreviewEnabledNotifier.value = v;
113
+ if (v) navigator.pop();
114
+ },
115
+ );
116
+ },
117
+ ),
118
+ ],
116
119
  const SettingsDivider(),
117
120
  SettingsTile(
118
121
  icon: KasyIcons.note,
@@ -186,10 +189,9 @@ class _AdminSheet extends ConsumerWidget {
186
189
  title: t.settings.admin.ask_notification,
187
190
  onTap: () {
188
191
  Navigator.pop(context);
189
- showNotificationPermissionSheetIfNeeded(
190
- navigatorKey.currentContext!,
191
- force: true,
192
- );
192
+ ref
193
+ .read(notificationsSettingsProvider)
194
+ .askPermission();
193
195
  },
194
196
  ),
195
197
  const SettingsDivider(),
@@ -1,5 +1,8 @@
1
+ import 'dart:async';
2
+
1
3
  import 'package:flutter/material.dart';
2
4
  import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+ import 'package:kasy_kit/core/home_widgets/home_widget_mywidget_service.dart';
3
6
  import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
4
7
  import 'package:kasy_kit/core/theme/theme.dart';
5
8
  import 'package:kasy_kit/features/settings/ui/widgets/settings_tile.dart';
@@ -93,12 +96,25 @@ class LanguageSwitcher extends ConsumerWidget {
93
96
  return _LocaleOptionTile(
94
97
  locale: locale,
95
98
  isSelected: isSelected,
96
- onTap: () async {
99
+ onTap: () {
100
+ // Close the sheet FIRST. Awaiting work before pop
101
+ // races with the rebuild triggered by setLocale and
102
+ // crashed Navigator.pop with !_debugLocked.
103
+ Navigator.pop(sheetContext);
97
104
  LocaleSettings.setLocale(locale);
98
- await ref
99
- .read(sharedPreferencesProvider)
100
- .setAppLocale(locale.languageCode);
101
- if (sheetContext.mounted) Navigator.pop(sheetContext);
105
+ // Re-render the home widget in the new language —
106
+ // fire-and-forget so the UI doesn't wait on a
107
+ // network call (RevenueCat) inside update().
108
+ unawaited(
109
+ ref
110
+ .read(sharedPreferencesProvider)
111
+ .setAppLocale(locale.languageCode),
112
+ );
113
+ unawaited(
114
+ ref
115
+ .read(myWidgetHomeWidgetProvider.notifier)
116
+ .updateForLocale(locale),
117
+ );
102
118
  },
103
119
  );
104
120
  }),
@@ -443,6 +443,7 @@
443
443
  "group_yesterday": "Yesterday",
444
444
  "group_older": "Older",
445
445
  "empty_cta": "Enable notifications",
446
+ "empty_cta_open_settings": "Open settings",
446
447
  "delete_all": "Delete all",
447
448
  "delete_all_confirm_title": "Delete all notifications?",
448
449
  "delete_all_confirm_message": "This will permanently remove every notification from your account. This cannot be undone.",
@@ -527,8 +528,10 @@
527
528
  "greeting_evening": "Good evening",
528
529
  "title_with_name": "Hi, $name!",
529
530
  "title_default": "Hi there!",
531
+ "title_logged_out": "We'll be here when you're back",
530
532
  "plan_free": "Free plan",
531
- "plan_pro": "PRO"
533
+ "plan_pro": "PRO",
534
+ "quote": "It always seems impossible until it's done."
532
535
  }
533
536
  }
534
537