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.
- package/bin/kasy.js +122 -7
- 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 +20 -5
- package/lib/commands/new.js +8 -20
- package/lib/commands/remove.js +1 -1
- package/lib/commands/reset.js +287 -0
- package/lib/commands/run.js +24 -17
- package/lib/commands/splash.js +3 -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 +85 -16
- package/lib/utils/checks.js +4 -105
- package/lib/utils/flutter-run.js +173 -0
- package/lib/utils/i18n.js +335 -0
- package/lib/utils/mobile-identity.js +35 -0
- package/lib/utils/ui.js +114 -0
- package/package.json +1 -2
- 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 +160 -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/layout/widget_loading.xml +8 -0
- package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +46 -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/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/kasy_button.dart +8 -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 +67 -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_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 +6 -1
- 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 +3 -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
|
@@ -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
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
CHANGED
|
Binary file
|
package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
CHANGED
|
Binary file
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
<string>pt-BR</string>
|
|
19
19
|
</array>
|
|
20
20
|
<key>CFBundleDisplayName</key>
|
|
21
|
-
<string>
|
|
21
|
+
<string>Kasy App</string>
|
|
22
22
|
<key>CFBundleExecutable</key>
|
|
23
23
|
<string>$(EXECUTABLE_NAME)</string>
|
|
24
24
|
<key>CFBundleIdentifier</key>
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
<key>FacebookClientToken</key>
|
|
58
58
|
<string>00000000000000000000000000000000</string>
|
|
59
59
|
<key>FacebookDisplayName</key>
|
|
60
|
-
<string>
|
|
60
|
+
<string>Kasy App</string>
|
|
61
61
|
<key>LSRequiresIPhoneOS</key>
|
|
62
62
|
<true/>
|
|
63
63
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* Localized Info.plist strings — es resources for system picker/camera UI (iOS). */
|
|
2
|
-
"CFBundleDisplayName" = "
|
|
2
|
+
"CFBundleDisplayName" = "Kasy App";
|
|
3
3
|
"NSCameraUsageDescription" = "Necesitamos la cámara para tomar fotos y vídeos.";
|
|
4
4
|
"NSPhotoLibraryAddUsageDescription" = "Necesitamos acceso para guardar fotos y vídeos en la galería.";
|
|
5
5
|
"NSPhotoLibraryUsageDescription" = "Necesitamos acceso para mostrar tus fotos recientes y abrir la galería desde la cámara.";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* Localized Info.plist strings — presence of pt-BR resources lets iOS use Portuguese for system UI (e.g. image picker). */
|
|
2
|
-
"CFBundleDisplayName" = "
|
|
2
|
+
"CFBundleDisplayName" = "Kasy App";
|
|
3
3
|
"NSCameraUsageDescription" = "Precisamos da câmera para tirar fotos e vídeos.";
|
|
4
4
|
"NSPhotoLibraryAddUsageDescription" = "Precisamos de acesso para salvar fotos e vídeos na galeria.";
|
|
5
5
|
"NSPhotoLibraryUsageDescription" = "Precisamos de acesso para mostrar suas fotos recentes e abrir a galeria a partir da câmera.";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* Localized Info.plist strings — presence of pt-BR resources lets iOS use Portuguese for system UI (e.g. image picker). */
|
|
2
|
-
"CFBundleDisplayName" = "
|
|
2
|
+
"CFBundleDisplayName" = "Kasy App";
|
|
3
3
|
"NSCameraUsageDescription" = "Precisamos da câmera para tirar fotos e vídeos.";
|
|
4
4
|
"NSPhotoLibraryAddUsageDescription" = "Precisamos de acesso para salvar fotos e vídeos na galeria.";
|
|
5
5
|
"NSPhotoLibraryUsageDescription" = "Precisamos de acesso para mostrar suas fotos recentes e abrir a galeria a partir da câmera.";
|
|
@@ -377,6 +377,9 @@ class KasyButton extends StatelessWidget {
|
|
|
377
377
|
}
|
|
378
378
|
final KasyColors c = context.colors;
|
|
379
379
|
final Color soft = c.surfaceNeutralSoft;
|
|
380
|
+
// Variants used on non-theme-matched backgrounds (e.g. inverse on the paywall gradient)
|
|
381
|
+
// need an explicit case — the generic disabled fallback blends with surfaceNeutralSoft,
|
|
382
|
+
// which goes near-black in dark mode and kills contrast on colored surfaces.
|
|
380
383
|
return switch (variant) {
|
|
381
384
|
KasyButtonVariant.primary => _KasyButtonPalette(
|
|
382
385
|
background: Color.alphaBlend(c.primary.withValues(alpha: 0.62), soft),
|
|
@@ -388,6 +391,11 @@ class KasyButton extends StatelessWidget {
|
|
|
388
391
|
foreground: c.primary.withValues(alpha: 0.90),
|
|
389
392
|
border: Colors.transparent,
|
|
390
393
|
),
|
|
394
|
+
KasyButtonVariant.inverse => _KasyButtonPalette(
|
|
395
|
+
background: c.onPrimary,
|
|
396
|
+
foreground: c.primary.withValues(alpha: 0.62),
|
|
397
|
+
border: Colors.transparent,
|
|
398
|
+
),
|
|
391
399
|
_ => null,
|
|
392
400
|
};
|
|
393
401
|
}
|
|
@@ -96,11 +96,29 @@ final class KasyPaddedSurfaceBottomBarFactory extends BartBottomBarFactory {
|
|
|
96
96
|
),
|
|
97
97
|
Theme(
|
|
98
98
|
data: Theme.of(context).copyWith(
|
|
99
|
+
splashFactory: NoSplash.splashFactory,
|
|
100
|
+
splashColor: Colors.transparent,
|
|
101
|
+
highlightColor: Colors.transparent,
|
|
99
102
|
navigationBarTheme: NavigationBarTheme.of(context).copyWith(
|
|
100
103
|
backgroundColor: Colors.transparent,
|
|
101
104
|
elevation: 0,
|
|
102
105
|
shadowColor: Colors.transparent,
|
|
103
106
|
surfaceTintColor: Colors.transparent,
|
|
107
|
+
indicatorColor: Colors.transparent,
|
|
108
|
+
iconTheme: WidgetStateProperty.resolveWith((states) {
|
|
109
|
+
final selected = states.contains(WidgetState.selected);
|
|
110
|
+
return IconThemeData(
|
|
111
|
+
color: selected ? colors.primary : colors.muted,
|
|
112
|
+
);
|
|
113
|
+
}),
|
|
114
|
+
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
|
115
|
+
final selected = states.contains(WidgetState.selected);
|
|
116
|
+
final base = Theme.of(context).textTheme.labelMedium ??
|
|
117
|
+
const TextStyle();
|
|
118
|
+
return base.copyWith(
|
|
119
|
+
color: selected ? colors.primary : colors.muted,
|
|
120
|
+
);
|
|
121
|
+
}),
|
|
104
122
|
),
|
|
105
123
|
),
|
|
106
124
|
child: BartMaterial3BottomBar(
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import 'dart:async';
|
|
2
|
+
import 'dart:io' show Platform;
|
|
3
|
+
|
|
4
|
+
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
1
5
|
import 'package:home_widget/home_widget.dart';
|
|
2
6
|
import 'package:kasy_kit/core/data/models/user.dart';
|
|
3
7
|
import 'package:kasy_kit/core/home_widgets/home_widget_service.dart';
|
|
4
|
-
import 'package:kasy_kit/core/states/translations.dart';
|
|
5
8
|
import 'package:kasy_kit/core/states/user_state_notifier.dart';
|
|
6
9
|
import 'package:kasy_kit/features/subscription/repositories/subscription_repository.dart';
|
|
7
10
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
@@ -19,10 +22,11 @@ class MyWidgetHomeWidget extends _$MyWidgetHomeWidget
|
|
|
19
22
|
@override
|
|
20
23
|
void build() {
|
|
21
24
|
// Auto-refresh the widget whenever user state changes in a way that
|
|
22
|
-
// affects what it renders (login/logout, name, premium status).
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
25
|
+
// affects what it renders (login/logout, name, email, premium status).
|
|
26
|
+
// The initial render is triggered explicitly by HomeWidgetsManager.init()
|
|
27
|
+
// after setAppGroupId completes — putting it here would race with the
|
|
28
|
+
// app-group setup and the first saveWidgetData could land in the wrong
|
|
29
|
+
// UserDefaults suite.
|
|
26
30
|
ref.listen(userStateNotifierProvider, (previous, next) {
|
|
27
31
|
if (previous == null) return;
|
|
28
32
|
if (_widgetSignature(previous.user) != _widgetSignature(next.user)) {
|
|
@@ -33,7 +37,7 @@ class MyWidgetHomeWidget extends _$MyWidgetHomeWidget
|
|
|
33
37
|
|
|
34
38
|
/// Snapshot of the user fields the widget reads. Used to skip the update
|
|
35
39
|
/// when an unrelated field changes (e.g. lastUpdateDate refresh).
|
|
36
|
-
(String?, String?, bool) _widgetSignature(User user) {
|
|
40
|
+
(String?, String?, String?, bool) _widgetSignature(User user) {
|
|
37
41
|
final isPro = switch (user) {
|
|
38
42
|
AuthenticatedUserData(:final subscription) ||
|
|
39
43
|
AnonymousUserData(:final subscription) =>
|
|
@@ -44,34 +48,62 @@ class MyWidgetHomeWidget extends _$MyWidgetHomeWidget
|
|
|
44
48
|
AuthenticatedUserData(:final name) => name,
|
|
45
49
|
_ => null,
|
|
46
50
|
};
|
|
47
|
-
|
|
51
|
+
final email = switch (user) {
|
|
52
|
+
AuthenticatedUserData(:final email) => email,
|
|
53
|
+
_ => null,
|
|
54
|
+
};
|
|
55
|
+
return (user.idOrNull, name, email, isPro);
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
@override
|
|
51
|
-
Future<void> update()
|
|
59
|
+
Future<void> update() => updateForLocale(LocaleSettings.currentLocale);
|
|
60
|
+
|
|
61
|
+
/// Same as [update] but renders against an explicit locale. Use this
|
|
62
|
+
/// from the language picker so the widget never falls one step behind:
|
|
63
|
+
/// `LocaleSettings.setLocale` propagates to `currentLocale` over a
|
|
64
|
+
/// frame boundary, and an [update] call scheduled at the same time
|
|
65
|
+
/// can race with it. Passing the locale removes the race.
|
|
66
|
+
Future<void> updateForLocale(AppLocale locale) async {
|
|
52
67
|
final logger = Logger();
|
|
53
|
-
logger.i('🔄 Updating MyWidget Home Widget');
|
|
68
|
+
logger.i('🔄 Updating MyWidget Home Widget (${locale.languageCode})');
|
|
54
69
|
final user = ref.read(userStateNotifierProvider).user;
|
|
55
|
-
final t =
|
|
70
|
+
final t = locale.translations;
|
|
71
|
+
|
|
72
|
+
// "Logged out" = no user id at all (post-logout in authRequired mode, or
|
|
73
|
+
// before any anonymous signup completes). In this state we show a
|
|
74
|
+
// come-back message and hide the plan tag — showing a plan would be
|
|
75
|
+
// misleading when there is no account behind it.
|
|
76
|
+
final isLoggedOut = user.idOrNull == null;
|
|
56
77
|
|
|
57
78
|
final name = switch (user) {
|
|
58
79
|
AuthenticatedUserData(:final name)
|
|
59
80
|
when name != null && name.isNotEmpty =>
|
|
60
81
|
name.split(' ').first,
|
|
82
|
+
// Fallback when the Firestore profile has no name yet: derive a
|
|
83
|
+
// display name from the email local-part (matches what the
|
|
84
|
+
// settings page shows).
|
|
85
|
+
AuthenticatedUserData(:final email) => email.split('@').first,
|
|
61
86
|
_ => null,
|
|
62
87
|
};
|
|
63
88
|
|
|
64
|
-
final isPro = await _resolveIsPro(user);
|
|
89
|
+
final isPro = !isLoggedOut && await _resolveIsPro(user);
|
|
65
90
|
|
|
66
91
|
final greeting = _greeting(t);
|
|
67
|
-
final title =
|
|
68
|
-
? t.home_widget.
|
|
69
|
-
:
|
|
70
|
-
|
|
92
|
+
final title = isLoggedOut
|
|
93
|
+
? t.home_widget.title_logged_out
|
|
94
|
+
: name == null
|
|
95
|
+
? t.home_widget.title_default
|
|
96
|
+
: t.home_widget.title_with_name(name: name);
|
|
97
|
+
// Empty planText is the contract used by the native widget to skip
|
|
98
|
+
// rendering the pill — see MyWidget.swift / MyWidget.kt.
|
|
99
|
+
final planText = isLoggedOut
|
|
100
|
+
? ''
|
|
101
|
+
: (isPro ? t.home_widget.plan_pro : t.home_widget.plan_free);
|
|
102
|
+
final quote = t.home_widget.quote;
|
|
71
103
|
|
|
72
104
|
logger.d(
|
|
73
105
|
'Widget payload → greeting: "$greeting", title: "$title", '
|
|
74
|
-
'planText: "$planText", isPro: $isPro',
|
|
106
|
+
'planText: "$planText", isPro: $isPro, loggedOut: $isLoggedOut',
|
|
75
107
|
);
|
|
76
108
|
|
|
77
109
|
return updateWidget({
|
|
@@ -79,6 +111,7 @@ class MyWidgetHomeWidget extends _$MyWidgetHomeWidget
|
|
|
79
111
|
'title': title,
|
|
80
112
|
'planText': planText,
|
|
81
113
|
'isPro': isPro.toString(),
|
|
114
|
+
'quote': quote,
|
|
82
115
|
});
|
|
83
116
|
}
|
|
84
117
|
|
|
@@ -98,9 +131,14 @@ class MyWidgetHomeWidget extends _$MyWidgetHomeWidget
|
|
|
98
131
|
if (userId == null) return cached;
|
|
99
132
|
try {
|
|
100
133
|
final repo = ref.read(subscriptionRepositoryProvider);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
134
|
+
// 2s timeout — if RevenueCat/network is slow, fall back to the cached
|
|
135
|
+
// value so the widget renders promptly on first install. The next
|
|
136
|
+
// background tick (or any user-state change) will reconcile later.
|
|
137
|
+
return await Future(() async {
|
|
138
|
+
await repo.initUser(userId);
|
|
139
|
+
final fresh = await repo.get(userId);
|
|
140
|
+
return fresh.isActive;
|
|
141
|
+
}).timeout(const Duration(seconds: 2), onTimeout: () => cached);
|
|
104
142
|
} catch (e) {
|
|
105
143
|
Logger().w('Widget could not refresh subscription: $e (using cached)');
|
|
106
144
|
return cached;
|
|
@@ -112,6 +150,16 @@ class MyWidgetHomeWidget extends _$MyWidgetHomeWidget
|
|
|
112
150
|
await HomeWidget.saveWidgetData<String>('title', data['title'] ?? '');
|
|
113
151
|
await HomeWidget.saveWidgetData<String>('planText', data['planText'] ?? '');
|
|
114
152
|
await HomeWidget.saveWidgetData<String>('isPro', data['isPro'] ?? 'false');
|
|
153
|
+
await HomeWidget.saveWidgetData<String>('quote', data['quote'] ?? '');
|
|
154
|
+
|
|
155
|
+
// On Android, saveWidgetData writes to SharedPreferences asynchronously.
|
|
156
|
+
// Glance's HomeWidgetGlanceStateDefinition reads from the same prefs, but
|
|
157
|
+
// a tight saveWidgetData→updateWidget sequence can race with the commit —
|
|
158
|
+
// Glance occasionally recomposes with the previous values (most visible
|
|
159
|
+
// right after a locale change). A small yield lets the writes settle.
|
|
160
|
+
if (!kIsWeb && Platform.isAndroid) {
|
|
161
|
+
await Future<void>.delayed(const Duration(milliseconds: 120));
|
|
162
|
+
}
|
|
115
163
|
|
|
116
164
|
await HomeWidget.updateWidget(
|
|
117
165
|
name: _androidWidgetName,
|