kasy-cli 1.13.0 → 1.15.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 (157) hide show
  1. package/bin/kasy.js +140 -12
  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 +28 -7
  8. package/lib/commands/new.js +8 -20
  9. package/lib/commands/remove.js +1 -1
  10. package/lib/commands/reset.js +385 -0
  11. package/lib/commands/run.js +24 -17
  12. package/lib/commands/splash.js +14 -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 +115 -16
  26. package/lib/utils/checks.js +45 -107
  27. package/lib/utils/debug.js +75 -0
  28. package/lib/utils/flutter-run.js +173 -0
  29. package/lib/utils/friendly-error.js +91 -0
  30. package/lib/utils/i18n/messages-en.js +970 -0
  31. package/lib/utils/i18n/messages-es.js +968 -0
  32. package/lib/utils/i18n/messages-pt.js +968 -0
  33. package/lib/utils/i18n.js +21 -2483
  34. package/lib/utils/mobile-identity.js +35 -0
  35. package/lib/utils/png-padding.js +120 -0
  36. package/lib/utils/ui.js +114 -0
  37. package/package.json +8 -4
  38. package/templates/firebase/README.en.md +1 -1
  39. package/templates/firebase/README.es.md +1 -1
  40. package/templates/firebase/README.md +1 -1
  41. package/templates/firebase/android/app/build.gradle.kts +10 -1
  42. package/templates/firebase/android/app/src/main/AndroidManifest.xml +1 -1
  43. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +25 -1
  44. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +161 -11
  45. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +15 -0
  46. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +9 -0
  47. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +12 -0
  48. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +5 -0
  49. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +17 -0
  50. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +5 -0
  51. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png +0 -0
  53. package/templates/firebase/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png +0 -0
  54. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  55. package/templates/firebase/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png +0 -0
  56. package/templates/firebase/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png +0 -0
  57. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  58. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  59. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  60. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  61. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  62. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  63. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png +0 -0
  64. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png +0 -0
  65. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  66. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png +0 -0
  67. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png +0 -0
  68. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  69. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png +0 -0
  70. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png +0 -0
  71. package/templates/firebase/android/app/src/main/res/layout/widget_loading.xml +8 -0
  72. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +53 -0
  73. package/templates/firebase/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +9 -0
  74. package/templates/firebase/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  75. package/templates/firebase/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  76. package/templates/firebase/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  77. package/templates/firebase/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  78. package/templates/firebase/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  79. package/templates/firebase/android/app/src/main/res/xml/mywidget_info.xml +9 -3
  80. package/templates/firebase/assets/images/favicon.png +0 -0
  81. package/templates/firebase/assets/images/icon.png +0 -0
  82. package/templates/firebase/assets/images/icon_android.png +0 -0
  83. package/templates/firebase/assets/images/icon_foreground_empty.png +0 -0
  84. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  85. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  86. package/templates/firebase/firestore.indexes.json +10 -0
  87. package/templates/firebase/functions/src/core/data/entities/user_device_entity.ts +3 -0
  88. package/templates/firebase/functions/src/core/data/repositories/user_device_repository.ts +17 -1
  89. package/templates/firebase/functions/src/index.ts +1 -0
  90. package/templates/firebase/functions/src/notifications/device_triggers.ts +58 -0
  91. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +116 -33
  92. package/templates/firebase/ios/Runner/AppDelegate.swift +17 -1
  93. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
  94. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
  95. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
  96. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
  97. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
  98. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
  99. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
  100. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
  101. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
  102. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
  103. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png +0 -0
  104. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png +0 -0
  105. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png +0 -0
  106. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png +0 -0
  107. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
  108. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
  109. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png +0 -0
  110. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png +0 -0
  111. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
  112. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
  113. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
  114. package/templates/firebase/ios/Runner/Info.plist +2 -2
  115. package/templates/firebase/ios/Runner/es.lproj/InfoPlist.strings +1 -1
  116. package/templates/firebase/ios/Runner/pt-BR.lproj/InfoPlist.strings +1 -1
  117. package/templates/firebase/ios/Runner/pt.lproj/InfoPlist.strings +1 -1
  118. package/templates/firebase/lib/components/components.dart +1 -0
  119. package/templates/firebase/lib/components/kasy_avatar.dart +88 -57
  120. package/templates/firebase/lib/components/kasy_avatar_presets.dart +116 -74
  121. package/templates/firebase/lib/components/kasy_button.dart +8 -0
  122. package/templates/firebase/lib/components/kasy_tabs.dart +431 -0
  123. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +18 -0
  124. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +73 -19
  125. package/templates/firebase/lib/core/home_widgets/home_widget_service.dart +22 -8
  126. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +16 -4
  127. package/templates/firebase/lib/core/states/components/maybeshow_component.dart +4 -8
  128. package/templates/firebase/lib/core/states/user_state_notifier.dart +13 -1
  129. package/templates/firebase/lib/features/home/home_components_page.dart +1 -1
  130. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +316 -93
  131. package/templates/firebase/lib/features/home/home_page.dart +0 -6
  132. package/templates/firebase/lib/features/notifications/api/device_api.dart +57 -0
  133. package/templates/firebase/lib/features/notifications/providers/models/notification.dart +11 -1
  134. package/templates/firebase/lib/features/notifications/repositories/device_repository.dart +9 -0
  135. package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +1 -4
  136. package/templates/firebase/lib/features/notifications/shared/att_permission.dart +28 -8
  137. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -1
  138. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +44 -11
  139. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +31 -29
  140. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +21 -5
  141. package/templates/firebase/lib/i18n/en.i18n.json +4 -1
  142. package/templates/firebase/lib/i18n/es.i18n.json +4 -1
  143. package/templates/firebase/lib/i18n/pt.i18n.json +4 -1
  144. package/templates/firebase/pubspec.yaml +10 -3
  145. package/templates/firebase/test/features/notifications/data/device_api_fake.dart +9 -0
  146. package/templates/firebase/web/favicon.png +0 -0
  147. package/templates/firebase/web/icons/Icon-192.png +0 -0
  148. package/templates/firebase/web/icons/Icon-512.png +0 -0
  149. package/templates/firebase/web/icons/Icon-maskable-192.png +0 -0
  150. package/templates/firebase/web/icons/Icon-maskable-512.png +0 -0
  151. package/templates/firebase/web/index.html +9 -0
  152. package/templates/firebase/web/manifest.json +3 -3
  153. package/templates/firebase/assets/images/app_icon.png +0 -0
  154. package/templates/firebase/assets/images/onboarding/img1.jpg +0 -0
  155. package/templates/firebase/assets/images/onboarding/onboarding.png +0 -0
  156. package/templates/firebase/lib/core/states/components/maybe_ask_biometric_setup.dart +0 -88
  157. package/templates/firebase/lib/features/notifications/shared/notification_permission_bottom_sheet.dart +0 -144
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Same gradient as widget_gradient_bg, but without rounded corners.
3
+ Used as the background of the Glance widget itself — the system
4
+ already clips the widget with the OS-provided corner radius, so
5
+ adding corners here would cause a visible double-radius edge. -->
6
+ <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
7
+ <gradient
8
+ android:angle="315"
9
+ android:startColor="#FF140829"
10
+ android:endColor="#FF33176B"
11
+ android:type="linear" />
12
+ </shape>
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
3
+ <corners android:radius="999dp"/>
4
+ <solid android:color="#14FFFFFF"/>
5
+ </shape>
@@ -0,0 +1,17 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Fallback preview shown in the widget gallery on launchers that don't
3
+ honor android:previewLayout (Android < 12, or some OEM launchers).
4
+ Layer list: rounded gradient + a hint star to suggest the PRO badge.
5
+ The actual widget overrides this once placed. -->
6
+ <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
7
+ <item>
8
+ <shape android:shape="rectangle">
9
+ <gradient
10
+ android:angle="315"
11
+ android:startColor="#FF140829"
12
+ android:endColor="#FF33176B"
13
+ android:type="linear" />
14
+ <corners android:radius="24dp" />
15
+ </shape>
16
+ </item>
17
+ </layer-list>
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
3
+ <corners android:radius="999dp"/>
4
+ <solid android:color="#2EFFD700"/>
5
+ </shape>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Brief-flash layout shown for ~200ms while Glance composes the real
3
+ widget. Just the gradient — no text — so the user never sees the
4
+ gray default loading layout from the home_widget library. -->
5
+ <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
6
+ android:layout_width="match_parent"
7
+ android:layout_height="match_parent"
8
+ android:background="@drawable/widget_gradient_bg" />
@@ -0,0 +1,53 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Static preview shown in the widget gallery (Android 12+).
3
+ Only uses Views allowed by RemoteViews — no <Space>, no
4
+ paddingHorizontal/Vertical, no fontFamily. Anything outside
5
+ the allowlist makes the launcher silently fall back to a gray box. -->
6
+ <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
7
+ android:layout_width="match_parent"
8
+ android:layout_height="match_parent"
9
+ android:background="@drawable/widget_gradient_bg"
10
+ android:orientation="vertical"
11
+ android:paddingLeft="16dp"
12
+ android:paddingRight="16dp"
13
+ android:paddingTop="16dp"
14
+ android:paddingBottom="16dp">
15
+
16
+ <TextView
17
+ android:layout_width="wrap_content"
18
+ android:layout_height="wrap_content"
19
+ android:text="Boa noite"
20
+ android:textColor="#8CFFFFFF"
21
+ android:textSize="11sp" />
22
+
23
+ <TextView
24
+ android:layout_width="wrap_content"
25
+ android:layout_height="wrap_content"
26
+ android:layout_marginTop="4dp"
27
+ android:text="Olá!"
28
+ android:textColor="#FFFFFFFF"
29
+ android:textSize="22sp"
30
+ android:textStyle="bold" />
31
+
32
+ <!-- Filler row uses TextView with empty text + weight to push the pill
33
+ to the bottom (Space isn't on the RemoteViews allowlist). -->
34
+ <TextView
35
+ android:layout_width="match_parent"
36
+ android:layout_height="0dp"
37
+ android:layout_weight="1"
38
+ android:text="" />
39
+
40
+ <TextView
41
+ android:layout_width="wrap_content"
42
+ android:layout_height="wrap_content"
43
+ android:background="@drawable/widget_pro_pill_bg"
44
+ android:paddingLeft="10dp"
45
+ android:paddingRight="10dp"
46
+ android:paddingTop="5dp"
47
+ android:paddingBottom="5dp"
48
+ android:text="⭐ PRO"
49
+ android:textColor="#FFFFD700"
50
+ android:textSize="11sp"
51
+ android:textStyle="bold" />
52
+
53
+ </LinearLayout>
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <background android:drawable="@drawable/ic_launcher_background"/>
4
+ <foreground>
5
+ <inset
6
+ android:drawable="@drawable/ic_launcher_foreground"
7
+ android:inset="16%" />
8
+ </foreground>
9
+ </adaptive-icon>
@@ -1,11 +1,17 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
2
  <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
3
- android:initialLayout="@layout/glance_default_loading_layout"
3
+ android:initialLayout="@layout/widget_loading"
4
+ android:previewLayout="@layout/widget_preview"
5
+ android:previewImage="@drawable/widget_preview_image"
4
6
  android:minWidth="180dp"
5
7
  android:minHeight="180dp"
8
+ android:minResizeWidth="120dp"
9
+ android:minResizeHeight="120dp"
10
+ android:maxResizeWidth="320dp"
11
+ android:maxResizeHeight="320dp"
6
12
  android:resizeMode="horizontal|vertical"
7
- android:updatePeriodMillis="900000"
13
+ android:updatePeriodMillis="0"
8
14
  android:description="@string/widget_my_widget_description"
9
15
  android:targetCellWidth="2"
10
16
  android:targetCellHeight="2"
11
- />
17
+ android:widgetCategory="home_screen|keyguard" />
@@ -20,6 +20,16 @@
20
20
  "queryScope": "COLLECTION_GROUP"
21
21
  }
22
22
  ]
23
+ },
24
+ {
25
+ "collectionGroup": "devices",
26
+ "fieldPath": "token",
27
+ "indexes": [
28
+ {
29
+ "order": "ASCENDING",
30
+ "queryScope": "COLLECTION_GROUP"
31
+ }
32
+ ]
23
33
  }
24
34
  ]
25
35
  }
@@ -16,6 +16,9 @@ export interface UserDeviceEntityData {
16
16
  operatingSystem: UserDeviceTypes;
17
17
  type: UserDeviceTypes;
18
18
  creation_date: Timestamp;
19
+ // Heartbeat timestamp written by the Flutter client. The field name matches
20
+ // what the client actually serializes (camelCase) — see device_entity.g.dart.
21
+ lastUpdateDate?: Timestamp;
19
22
  extra_data?: { [key: string]: string };
20
23
  }
21
24
 
@@ -1,5 +1,12 @@
1
+ import {Timestamp} from "firebase-admin/firestore";
1
2
  import {UserDeviceAdapter, UserDeviceEntity} from "../entities/user_device_entity";
2
3
 
4
+ // Devices that did not heartbeat within this window are treated as orphans
5
+ // from prior installs (a fresh install creates a new doc with a different
6
+ // installationId). Skipping them avoids sending the same push multiple times
7
+ // to the same physical device after re-installs (e.g. Xcode -> TestFlight).
8
+ const STALE_DEVICE_TTL_MS = 60 * 24 * 60 * 60 * 1000;
9
+
3
10
  export class UserDevicesRepository {
4
11
  constructor(
5
12
  private db: FirebaseFirestore.Firestore,
@@ -17,10 +24,19 @@ export class UserDevicesRepository {
17
24
  }
18
25
 
19
26
  async getDevices(userIds: string[]): Promise<UserDeviceEntity[]> {
27
+ const cutoffMs = Date.now() - STALE_DEVICE_TTL_MS;
20
28
  const result: UserDeviceEntity[] = [];
21
29
  for (const userId of userIds) {
22
30
  const userResult = await this.collection(userId).get();
23
- result.push(...userResult.docs.map((doc) => doc.data()));
31
+ for (const doc of userResult.docs) {
32
+ const device = doc.data();
33
+ // Backward-compat: docs without lastUpdateDate (older app versions) pass through.
34
+ const lastUpdate = device.lastUpdateDate;
35
+ if (lastUpdate instanceof Timestamp && lastUpdate.toMillis() < cutoffMs) {
36
+ continue;
37
+ }
38
+ result.push(device);
39
+ }
24
40
  }
25
41
  return result;
26
42
  }
@@ -13,6 +13,7 @@ exports.authFunctions = require("./authentication/functions");
13
13
  // notifications
14
14
  exports.notificationsTriggers = require("./notifications/triggers");
15
15
  exports.notificationsFunctions = require("./notifications/admin_functions");
16
+ exports.deviceTriggers = require("./notifications/device_triggers");
16
17
 
17
18
  // subscriptions
18
19
  exports.subscriptions = require("./subscriptions/subscriptions_functions");
@@ -0,0 +1,58 @@
1
+ import * as admin from "firebase-admin";
2
+ import {onDocumentWritten} from "firebase-functions/v2/firestore";
3
+ import {Logger} from "../core/logger/logger";
4
+
5
+ /**
6
+ * Cross-user device token deduplication.
7
+ *
8
+ * Fires when any `users/{userId}/devices/{deviceId}` doc is written. If the
9
+ * same FCM token exists under another user (typical scenario: logout failed
10
+ * offline, then the same install registered under a new account), the older
11
+ * docs are deleted. Winner = the doc that was just written (most recent intent).
12
+ *
13
+ * This guarantees the invariant: one FCM token belongs to at most one user at
14
+ * any time. Without it, sending a push to user A could deliver to a phone now
15
+ * signed in as user B.
16
+ */
17
+ export const onDeviceWritten = onDocumentWritten(
18
+ "users/{userId}/devices/{deviceId}",
19
+ async (event) => {
20
+ const after = event.data?.after?.data();
21
+ if (!after) return; // Deletion — nothing to dedup against.
22
+
23
+ const token = after.token as string | undefined;
24
+ if (!token) return;
25
+
26
+ const currentUserId = event.params.userId;
27
+ const currentDeviceId = event.params.deviceId;
28
+ const logger = new Logger("onDeviceWritten");
29
+
30
+ try {
31
+ const duplicates = await admin
32
+ .firestore()
33
+ .collectionGroup("devices")
34
+ .where("token", "==", token)
35
+ .get();
36
+
37
+ const batch = admin.firestore().batch();
38
+ let staleCount = 0;
39
+ for (const doc of duplicates.docs) {
40
+ const parentUserId = doc.ref.parent.parent?.id;
41
+ if (parentUserId === currentUserId && doc.id === currentDeviceId) {
42
+ continue;
43
+ }
44
+ batch.delete(doc.ref);
45
+ staleCount++;
46
+ }
47
+
48
+ if (staleCount > 0) {
49
+ await batch.commit();
50
+ logger.info(
51
+ `Removed ${staleCount} duplicate device doc(s) for token …${token.slice(-8)} ` +
52
+ `after write at users/${currentUserId}/devices/${currentDeviceId}`,
53
+ );
54
+ }
55
+ } catch (e) {
56
+ logger.error(`Cross-user device dedup failed: ${e}`);
57
+ }
58
+ });
@@ -3,18 +3,11 @@ import SwiftUI
3
3
 
4
4
  struct MyWidgetProvider: TimelineProvider {
5
5
  func placeholder(in context: Context) -> MyWidgetEntry {
6
- MyWidgetEntry(date: Date(), greeting: "", title: "", planText: "", isPro: false)
6
+ MyWidgetEntry.defaults()
7
7
  }
8
8
 
9
9
  func getSnapshot(in context: Context, completion: @escaping (MyWidgetEntry) -> Void) {
10
- let prefs = UserDefaults(suiteName: "group.com.aicrus.firebase.kit")
11
- completion(MyWidgetEntry(
12
- date: Date(),
13
- greeting: prefs?.string(forKey: "greeting") ?? "",
14
- title: prefs?.string(forKey: "title") ?? "",
15
- planText: prefs?.string(forKey: "planText") ?? "",
16
- isPro: prefs?.string(forKey: "isPro") == "true"
17
- ))
10
+ completion(MyWidgetEntry.fromPrefs())
18
11
  }
19
12
 
20
13
  func getTimeline(in context: Context, completion: @escaping (Timeline<MyWidgetEntry>) -> Void) {
@@ -30,12 +23,62 @@ struct MyWidgetEntry: TimelineEntry {
30
23
  let title: String
31
24
  let planText: String
32
25
  let isPro: Bool
26
+ let quote: String
27
+
28
+ /// Reads the latest data from the shared app group. If a string was never
29
+ /// written (first install before the Flutter app pushed data), falls back
30
+ /// to a time-based greeting in the device language so the widget never
31
+ /// shows a blank gradient.
32
+ static func fromPrefs() -> MyWidgetEntry {
33
+ let prefs = UserDefaults(suiteName: "group.com.aicrus.firebase.kit")
34
+ let storedGreeting = prefs?.string(forKey: "greeting") ?? ""
35
+ let storedTitle = prefs?.string(forKey: "title") ?? ""
36
+ let storedPlan = prefs?.string(forKey: "planText") ?? ""
37
+ let storedIsPro = prefs?.string(forKey: "isPro") == "true"
38
+ let storedQuote = prefs?.string(forKey: "quote") ?? ""
39
+
40
+ let defaults = MyWidgetEntry.defaults()
41
+ return MyWidgetEntry(
42
+ date: Date(),
43
+ greeting: storedGreeting.isEmpty ? defaults.greeting : storedGreeting,
44
+ title: storedTitle.isEmpty ? defaults.title : storedTitle,
45
+ planText: storedPlan,
46
+ isPro: storedIsPro,
47
+ quote: storedQuote
48
+ )
49
+ }
50
+
51
+ /// Time-aware fallback used when the Flutter side has not yet written
52
+ /// data. Picks the language from the device locale so the first render
53
+ /// is at least in the user's language; once the app runs, the real
54
+ /// values (in the app locale) overwrite this.
55
+ static func defaults() -> MyWidgetEntry {
56
+ let hour = Calendar.current.component(.hour, from: Date())
57
+ let lang = Locale.current.language.languageCode?.identifier ?? "en"
58
+ let (morning, afternoon, evening, hello): (String, String, String, String)
59
+ switch lang {
60
+ case "pt": (morning, afternoon, evening, hello) = ("Bom dia", "Boa tarde", "Boa noite", "Olá!")
61
+ case "es": (morning, afternoon, evening, hello) = ("Buenos días", "Buenas tardes", "Buenas noches", "¡Hola!")
62
+ default: (morning, afternoon, evening, hello) = ("Good morning", "Good afternoon", "Good evening", "Hi there!")
63
+ }
64
+ let greeting: String
65
+ if hour < 12 { greeting = morning }
66
+ else if hour < 18 { greeting = afternoon }
67
+ else { greeting = evening }
68
+ return MyWidgetEntry(
69
+ date: Date(),
70
+ greeting: greeting,
71
+ title: hello,
72
+ planText: "",
73
+ isPro: false,
74
+ quote: ""
75
+ )
76
+ }
33
77
  }
34
78
 
35
79
  struct MyWidgetWidgetView: View {
36
80
  var entry: MyWidgetProvider.Entry
37
81
  @Environment(\.widgetFamily) var family
38
- @Environment(\.colorScheme) var colorScheme
39
82
 
40
83
  private var titleSize: CGFloat {
41
84
  switch family {
@@ -45,15 +88,6 @@ struct MyWidgetWidgetView: View {
45
88
  }
46
89
  }
47
90
 
48
- private var gradientColors: [Color] {
49
- // Dark theme stays the same in both light/dark modes for brand consistency;
50
- // tweak here if you want true light-mode variant.
51
- return [
52
- Color(red: 0.08, green: 0.03, blue: 0.16),
53
- Color(red: 0.20, green: 0.09, blue: 0.42),
54
- ]
55
- }
56
-
57
91
  var body: some View {
58
92
  VStack(alignment: .leading, spacing: 0) {
59
93
  Text(entry.greeting)
@@ -63,26 +97,69 @@ struct MyWidgetWidgetView: View {
63
97
 
64
98
  Spacer().frame(height: 6)
65
99
 
100
+ // Reserve room on the right so the title never sits flush against
101
+ // the widget edge (looks cramped, especially on small).
66
102
  Text(entry.title)
67
103
  .font(.system(size: titleSize, weight: .bold, design: .rounded))
68
104
  .foregroundStyle(.white)
69
105
  .lineLimit(2)
70
106
  .minimumScaleFactor(0.75)
107
+ .frame(maxWidth: .infinity, alignment: .leading)
108
+ .padding(.trailing, 8)
109
+
110
+ // Motivational quote only on the large widget — small/medium
111
+ // don't have the vertical room. Thin, low-emphasis typography
112
+ // so the title stays the hero element.
113
+ if family == .systemLarge && !entry.quote.isEmpty {
114
+ Spacer().frame(height: 12)
115
+ Text(entry.quote)
116
+ .font(.system(size: 15, weight: .light, design: .rounded))
117
+ .italic()
118
+ .foregroundStyle(.white.opacity(0.7))
119
+ .lineLimit(4)
120
+ .lineSpacing(2)
121
+ .frame(maxWidth: .infinity, alignment: .leading)
122
+ .padding(.trailing, 8)
123
+ }
71
124
 
72
125
  Spacer()
73
126
 
74
- if entry.isPro {
75
- Label(entry.planText, systemImage: "star.fill")
76
- .font(.system(size: 10, weight: .bold, design: .rounded))
77
- .foregroundStyle(Color(red: 1.0, green: 0.84, blue: 0.0))
78
- .padding(.horizontal, 8)
79
- .padding(.vertical, 4)
80
- .background(Color(red: 1.0, green: 0.84, blue: 0.0).opacity(0.18))
81
- .clipShape(Capsule())
82
- } else {
83
- Text(entry.planText)
84
- .font(.system(size: 10, weight: .medium, design: .rounded))
85
- .foregroundStyle(.white.opacity(0.4))
127
+ // Plan tag + (medium/large only) decorative "+" pill.
128
+ // Small intentionally drops the "+" so the layout breathes —
129
+ // the pill sits flush left like the original design.
130
+ // Empty planText hides the pill (used in logged-out state).
131
+ HStack(alignment: .center, spacing: 8) {
132
+ if !entry.planText.isEmpty {
133
+ if entry.isPro {
134
+ Label(entry.planText, systemImage: "star.fill")
135
+ .font(.system(size: 11, weight: .bold, design: .rounded))
136
+ .foregroundStyle(Color(red: 1.0, green: 0.84, blue: 0.0))
137
+ .padding(.horizontal, 10)
138
+ .padding(.vertical, 5)
139
+ .background(Color(red: 1.0, green: 0.84, blue: 0.0).opacity(0.18))
140
+ .clipShape(Capsule())
141
+ } else {
142
+ Text(entry.planText)
143
+ .font(.system(size: 11, weight: .medium, design: .rounded))
144
+ .foregroundStyle(.white.opacity(0.45))
145
+ .padding(.horizontal, 10)
146
+ .padding(.vertical, 5)
147
+ .background(Color.white.opacity(0.08))
148
+ .clipShape(Capsule())
149
+ }
150
+ }
151
+
152
+ if family != .systemSmall {
153
+ Spacer(minLength: 12)
154
+ ZStack {
155
+ Circle()
156
+ .fill(Color.white.opacity(0.18))
157
+ .frame(width: 34, height: 34)
158
+ Image(systemName: "plus")
159
+ .font(.system(size: 16, weight: .bold))
160
+ .foregroundStyle(.white)
161
+ }
162
+ }
86
163
  }
87
164
  }
88
165
  .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
@@ -116,11 +193,17 @@ struct MyWidgetWidget: Widget {
116
193
  #Preview("Small", as: .systemSmall) {
117
194
  MyWidgetWidget()
118
195
  } timeline: {
119
- MyWidgetEntry(date: .now, greeting: "Bom dia", title: "Olá, Paulo!", planText: "PRO", isPro: true)
196
+ MyWidgetEntry(date: .now, greeting: "Bom dia", title: "Olá, Paulo!", planText: "PRO", isPro: true, quote: "")
120
197
  }
121
198
 
122
199
  #Preview("Medium", as: .systemMedium) {
123
200
  MyWidgetWidget()
124
201
  } timeline: {
125
- MyWidgetEntry(date: .now, greeting: "Boa tarde", title: "Olá, Paulo!", planText: "Plano grátis", isPro: false)
202
+ MyWidgetEntry(date: .now, greeting: "Boa tarde", title: "Olá, Paulo!", planText: "Plano grátis", isPro: false, quote: "")
203
+ }
204
+
205
+ #Preview("Large", as: .systemLarge) {
206
+ MyWidgetWidget()
207
+ } timeline: {
208
+ MyWidgetEntry(date: .now, greeting: "Boa noite", title: "Olá, Paulo!", planText: "PRO", isPro: true, quote: "Sempre parece impossível, até que seja feito.")
126
209
  }
@@ -12,7 +12,23 @@ import home_widget
12
12
  _ application: UIApplication,
13
13
  didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
14
14
  ) -> Bool {
15
- return super.application(application, didFinishLaunchingWithOptions: launchOptions)
15
+ let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
16
+ applySavedThemeMode()
17
+ return result
18
+ }
19
+
20
+ // Forces the window's interface style to match the user's saved theme
21
+ // preference (read from `shared_preferences`) so the native splash overlay
22
+ // follows the in-app choice instead of only the OS brightness.
23
+ private func applySavedThemeMode() {
24
+ let saved = UserDefaults.standard.string(forKey: "flutter.themeMode")
25
+ let style: UIUserInterfaceStyle
26
+ switch saved {
27
+ case "dark": style = .dark
28
+ case "light": style = .light
29
+ default: style = .unspecified
30
+ }
31
+ window?.overrideUserInterfaceStyle = style
16
32
  }
17
33
 
18
34
  func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {