kasy-cli 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/bin/kasy.js +122 -7
  2. package/lib/commands/add.js +2 -2
  3. package/lib/commands/codemagic.js +11 -4
  4. package/lib/commands/deploy.js +3 -3
  5. package/lib/commands/favicon.js +115 -0
  6. package/lib/commands/icon.js +143 -0
  7. package/lib/commands/ios.js +20 -5
  8. package/lib/commands/new.js +8 -20
  9. package/lib/commands/remove.js +1 -1
  10. package/lib/commands/reset.js +287 -0
  11. package/lib/commands/run.js +24 -17
  12. package/lib/commands/splash.js +3 -4
  13. package/lib/commands/update.js +1 -1
  14. package/lib/scaffold/backends/api/patch/README.md +1 -1
  15. package/lib/scaffold/backends/api/patch/android/app/src/main/AndroidManifest.xml +1 -1
  16. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +53 -0
  17. package/lib/scaffold/backends/api/pubspec.yaml.tpl +11 -1
  18. package/lib/scaffold/backends/firebase/tokens.js +2 -2
  19. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -2
  20. package/lib/scaffold/backends/supabase/migrations/20240101000011_dedupe_device_tokens.sql +34 -0
  21. package/lib/scaffold/backends/supabase/patch/README.md +1 -1
  22. package/lib/scaffold/backends/supabase/patch/android/app/src/main/AndroidManifest.xml +1 -1
  23. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +43 -0
  24. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +11 -1
  25. package/lib/utils/apple-release.js +85 -16
  26. package/lib/utils/checks.js +4 -105
  27. package/lib/utils/flutter-run.js +173 -0
  28. package/lib/utils/i18n.js +335 -0
  29. package/lib/utils/mobile-identity.js +35 -0
  30. package/lib/utils/ui.js +114 -0
  31. package/package.json +1 -2
  32. package/templates/firebase/README.en.md +1 -1
  33. package/templates/firebase/README.es.md +1 -1
  34. package/templates/firebase/README.md +1 -1
  35. package/templates/firebase/android/app/build.gradle.kts +10 -1
  36. package/templates/firebase/android/app/src/main/AndroidManifest.xml +1 -1
  37. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +25 -1
  38. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +160 -11
  39. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +15 -0
  40. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +9 -0
  41. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +12 -0
  42. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +5 -0
  43. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +17 -0
  44. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +5 -0
  45. package/templates/firebase/android/app/src/main/res/layout/widget_loading.xml +8 -0
  46. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +46 -0
  47. package/templates/firebase/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  48. package/templates/firebase/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  49. package/templates/firebase/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  50. package/templates/firebase/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  51. package/templates/firebase/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/xml/mywidget_info.xml +9 -3
  53. package/templates/firebase/assets/images/favicon.png +0 -0
  54. package/templates/firebase/assets/images/icon.png +0 -0
  55. package/templates/firebase/firestore.indexes.json +10 -0
  56. package/templates/firebase/functions/src/core/data/entities/user_device_entity.ts +3 -0
  57. package/templates/firebase/functions/src/core/data/repositories/user_device_repository.ts +17 -1
  58. package/templates/firebase/functions/src/index.ts +1 -0
  59. package/templates/firebase/functions/src/notifications/device_triggers.ts +58 -0
  60. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +116 -33
  61. package/templates/firebase/ios/Runner/AppDelegate.swift +17 -1
  62. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
  63. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
  64. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
  65. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
  66. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
  67. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
  68. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
  69. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
  70. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
  71. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
  72. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png +0 -0
  73. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png +0 -0
  74. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png +0 -0
  75. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png +0 -0
  76. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
  77. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
  78. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png +0 -0
  79. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png +0 -0
  80. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
  81. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
  82. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
  83. package/templates/firebase/ios/Runner/Info.plist +2 -2
  84. package/templates/firebase/ios/Runner/es.lproj/InfoPlist.strings +1 -1
  85. package/templates/firebase/ios/Runner/pt-BR.lproj/InfoPlist.strings +1 -1
  86. package/templates/firebase/ios/Runner/pt.lproj/InfoPlist.strings +1 -1
  87. package/templates/firebase/lib/components/kasy_button.dart +8 -0
  88. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +18 -0
  89. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +67 -19
  90. package/templates/firebase/lib/core/home_widgets/home_widget_service.dart +22 -8
  91. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +16 -4
  92. package/templates/firebase/lib/core/states/components/maybeshow_component.dart +4 -8
  93. package/templates/firebase/lib/core/states/user_state_notifier.dart +13 -1
  94. package/templates/firebase/lib/features/home/home_page.dart +0 -6
  95. package/templates/firebase/lib/features/notifications/api/device_api.dart +57 -0
  96. package/templates/firebase/lib/features/notifications/providers/models/notification.dart +11 -1
  97. package/templates/firebase/lib/features/notifications/repositories/device_repository.dart +9 -0
  98. package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +1 -4
  99. package/templates/firebase/lib/features/notifications/shared/att_permission.dart +28 -8
  100. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -1
  101. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +44 -11
  102. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +31 -29
  103. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +21 -5
  104. package/templates/firebase/lib/i18n/en.i18n.json +4 -1
  105. package/templates/firebase/lib/i18n/es.i18n.json +4 -1
  106. package/templates/firebase/lib/i18n/pt.i18n.json +4 -1
  107. package/templates/firebase/pubspec.yaml +6 -1
  108. package/templates/firebase/test/features/notifications/data/device_api_fake.dart +9 -0
  109. package/templates/firebase/web/favicon.png +0 -0
  110. package/templates/firebase/web/icons/Icon-192.png +0 -0
  111. package/templates/firebase/web/icons/Icon-512.png +0 -0
  112. package/templates/firebase/web/icons/Icon-maskable-192.png +0 -0
  113. package/templates/firebase/web/icons/Icon-maskable-512.png +0 -0
  114. package/templates/firebase/web/index.html +3 -0
  115. package/templates/firebase/web/manifest.json +3 -3
  116. package/templates/firebase/assets/images/app_icon.png +0 -0
  117. package/templates/firebase/assets/images/onboarding/img1.jpg +0 -0
  118. package/templates/firebase/assets/images/onboarding/onboarding.png +0 -0
  119. package/templates/firebase/lib/core/states/components/maybe_ask_biometric_setup.dart +0 -88
  120. package/templates/firebase/lib/features/notifications/shared/notification_permission_bottom_sheet.dart +0 -144
@@ -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) {
@@ -18,7 +18,7 @@
18
18
  <string>pt-BR</string>
19
19
  </array>
20
20
  <key>CFBundleDisplayName</key>
21
- <string>AppFirebase</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>AppFirebase</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" = "AppFirebase";
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" = "AppFirebase";
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" = "AppFirebase";
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). Without
23
- // this, the widget would only update via the 15-min background task or
24
- // a manual triggera fresh subscription would not reflect on the home
25
- // screen until the next background tick.
25
+ // affects what it renders (login/logout, name, email, premium status).
26
+ // The initial render is triggered explicitly by HomeWidgetsManager.init()
27
+ // after setAppGroupId completesputting 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
- return (user.idOrNull, name, isPro);
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() async {
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 = ref.read(translationsProvider);
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 = name == null
68
- ? t.home_widget.title_default
69
- : t.home_widget.title_with_name(name: name);
70
- final planText = isPro ? t.home_widget.plan_pro : t.home_widget.plan_free;
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
- await repo.initUser(userId);
102
- final fresh = await repo.get(userId);
103
- return fresh.isActive;
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,