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.
- package/bin/kasy.js +140 -12
- package/lib/commands/add.js +2 -2
- package/lib/commands/codemagic.js +11 -4
- package/lib/commands/deploy.js +3 -3
- package/lib/commands/favicon.js +115 -0
- package/lib/commands/icon.js +143 -0
- package/lib/commands/ios.js +28 -7
- package/lib/commands/new.js +8 -20
- package/lib/commands/remove.js +1 -1
- package/lib/commands/reset.js +385 -0
- package/lib/commands/run.js +24 -17
- package/lib/commands/splash.js +14 -4
- package/lib/commands/update.js +1 -1
- package/lib/scaffold/backends/api/patch/README.md +1 -1
- package/lib/scaffold/backends/api/patch/android/app/src/main/AndroidManifest.xml +1 -1
- package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +53 -0
- package/lib/scaffold/backends/api/pubspec.yaml.tpl +11 -1
- package/lib/scaffold/backends/firebase/tokens.js +2 -2
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -2
- package/lib/scaffold/backends/supabase/migrations/20240101000011_dedupe_device_tokens.sql +34 -0
- package/lib/scaffold/backends/supabase/patch/README.md +1 -1
- package/lib/scaffold/backends/supabase/patch/android/app/src/main/AndroidManifest.xml +1 -1
- package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +43 -0
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +11 -1
- package/lib/utils/apple-release.js +115 -16
- package/lib/utils/checks.js +45 -107
- package/lib/utils/debug.js +75 -0
- package/lib/utils/flutter-run.js +173 -0
- package/lib/utils/friendly-error.js +91 -0
- package/lib/utils/i18n/messages-en.js +970 -0
- package/lib/utils/i18n/messages-es.js +968 -0
- package/lib/utils/i18n/messages-pt.js +968 -0
- package/lib/utils/i18n.js +21 -2483
- package/lib/utils/mobile-identity.js +35 -0
- package/lib/utils/png-padding.js +120 -0
- package/lib/utils/ui.js +114 -0
- package/package.json +8 -4
- package/templates/firebase/README.en.md +1 -1
- package/templates/firebase/README.es.md +1 -1
- package/templates/firebase/README.md +1 -1
- package/templates/firebase/android/app/build.gradle.kts +10 -1
- package/templates/firebase/android/app/src/main/AndroidManifest.xml +1 -1
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +25 -1
- package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +161 -11
- package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +15 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +9 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +12 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +5 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +17 -0
- package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +5 -0
- package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png +0 -0
- package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/templates/firebase/android/app/src/main/res/layout/widget_loading.xml +8 -0
- package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +53 -0
- package/templates/firebase/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +9 -0
- package/templates/firebase/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/templates/firebase/android/app/src/main/res/xml/mywidget_info.xml +9 -3
- package/templates/firebase/assets/images/favicon.png +0 -0
- package/templates/firebase/assets/images/icon.png +0 -0
- package/templates/firebase/assets/images/icon_android.png +0 -0
- package/templates/firebase/assets/images/icon_foreground_empty.png +0 -0
- package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
- package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
- package/templates/firebase/firestore.indexes.json +10 -0
- package/templates/firebase/functions/src/core/data/entities/user_device_entity.ts +3 -0
- package/templates/firebase/functions/src/core/data/repositories/user_device_repository.ts +17 -1
- package/templates/firebase/functions/src/index.ts +1 -0
- package/templates/firebase/functions/src/notifications/device_triggers.ts +58 -0
- package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +116 -33
- package/templates/firebase/ios/Runner/AppDelegate.swift +17 -1
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
- package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
- package/templates/firebase/ios/Runner/Info.plist +2 -2
- package/templates/firebase/ios/Runner/es.lproj/InfoPlist.strings +1 -1
- package/templates/firebase/ios/Runner/pt-BR.lproj/InfoPlist.strings +1 -1
- package/templates/firebase/ios/Runner/pt.lproj/InfoPlist.strings +1 -1
- package/templates/firebase/lib/components/components.dart +1 -0
- package/templates/firebase/lib/components/kasy_avatar.dart +88 -57
- package/templates/firebase/lib/components/kasy_avatar_presets.dart +116 -74
- package/templates/firebase/lib/components/kasy_button.dart +8 -0
- package/templates/firebase/lib/components/kasy_tabs.dart +431 -0
- package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +18 -0
- package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +73 -19
- package/templates/firebase/lib/core/home_widgets/home_widget_service.dart +22 -8
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +16 -4
- package/templates/firebase/lib/core/states/components/maybeshow_component.dart +4 -8
- package/templates/firebase/lib/core/states/user_state_notifier.dart +13 -1
- package/templates/firebase/lib/features/home/home_components_page.dart +1 -1
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +316 -93
- package/templates/firebase/lib/features/home/home_page.dart +0 -6
- package/templates/firebase/lib/features/notifications/api/device_api.dart +57 -0
- package/templates/firebase/lib/features/notifications/providers/models/notification.dart +11 -1
- package/templates/firebase/lib/features/notifications/repositories/device_repository.dart +9 -0
- package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +1 -4
- package/templates/firebase/lib/features/notifications/shared/att_permission.dart +28 -8
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -1
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +44 -11
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +31 -29
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +21 -5
- package/templates/firebase/lib/i18n/en.i18n.json +4 -1
- package/templates/firebase/lib/i18n/es.i18n.json +4 -1
- package/templates/firebase/lib/i18n/pt.i18n.json +4 -1
- package/templates/firebase/pubspec.yaml +10 -3
- package/templates/firebase/test/features/notifications/data/device_api_fake.dart +9 -0
- package/templates/firebase/web/favicon.png +0 -0
- package/templates/firebase/web/icons/Icon-192.png +0 -0
- package/templates/firebase/web/icons/Icon-512.png +0 -0
- package/templates/firebase/web/icons/Icon-maskable-192.png +0 -0
- package/templates/firebase/web/icons/Icon-maskable-512.png +0 -0
- package/templates/firebase/web/index.html +9 -0
- package/templates/firebase/web/manifest.json +3 -3
- package/templates/firebase/assets/images/app_icon.png +0 -0
- package/templates/firebase/assets/images/onboarding/img1.jpg +0 -0
- package/templates/firebase/assets/images/onboarding/onboarding.png +0 -0
- package/templates/firebase/lib/core/states/components/maybe_ask_biometric_setup.dart +0 -88
- 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,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>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png
CHANGED
|
Binary file
|
package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png
CHANGED
|
Binary file
|
package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png
ADDED
|
Binary file
|
package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
ADDED
|
Binary file
|
|
Binary file
|
package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png
ADDED
|
Binary file
|
package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
ADDED
|
Binary file
|
|
@@ -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>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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/
|
|
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="
|
|
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" />
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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(
|
|
6
|
+
MyWidgetEntry.defaults()
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
func getSnapshot(in context: Context, completion: @escaping (MyWidgetEntry) -> Void) {
|
|
10
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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) {
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
CHANGED
|
Binary file
|