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
package/lib/utils/ui.js CHANGED
@@ -13,6 +13,15 @@
13
13
  */
14
14
 
15
15
  const clack = require('@clack/prompts');
16
+ const kleur = require('kleur');
17
+
18
+ function formatElapsedSeconds(startTime) {
19
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
20
+ if (elapsed < 60) return `${elapsed}s`;
21
+ const m = Math.floor(elapsed / 60);
22
+ const s = elapsed % 60;
23
+ return `${m}m ${s}s`;
24
+ }
16
25
 
17
26
  function handleCancel(result, onCancel) {
18
27
  if (clack.isCancel(result)) {
@@ -85,6 +94,109 @@ function cancel(message) { clack.cancel(message); }
85
94
  */
86
95
  function spinner() { return clack.spinner(); }
87
96
 
97
+ /**
98
+ * Spinner with an automatic elapsed-time suffix that ticks every second.
99
+ * Same API as spinner() — useful for operations that take >30s so the user
100
+ * sees the clock moving even when the underlying tool emits no progress.
101
+ *
102
+ * const s = ui.timedSpinner();
103
+ * s.start('Deploying…'); // "Deploying… [0s]"
104
+ * // ... 73 seconds later
105
+ * s.stop('Deploy done'); // "Deploy done [1m 13s]"
106
+ */
107
+ function timedSpinner() {
108
+ const s = clack.spinner();
109
+ let startTime = null;
110
+ let currentMessage = '';
111
+ let tick = null;
112
+
113
+ const render = (msg) => {
114
+ if (!msg) return '';
115
+ if (!startTime) return msg;
116
+ return `${msg} ${kleur.dim(`[${formatElapsedSeconds(startTime)}]`)}`;
117
+ };
118
+
119
+ const stopTick = () => {
120
+ if (tick) { clearInterval(tick); tick = null; }
121
+ };
122
+
123
+ return {
124
+ start(msg) {
125
+ startTime = Date.now();
126
+ currentMessage = msg || '';
127
+ s.start(render(currentMessage));
128
+ tick = setInterval(() => {
129
+ if (currentMessage) s.message(render(currentMessage));
130
+ }, 1000);
131
+ },
132
+ message(msg) {
133
+ currentMessage = msg || '';
134
+ s.message(render(currentMessage));
135
+ },
136
+ stop(msg, code) {
137
+ stopTick();
138
+ const finalMsg = msg != null ? render(msg) : render(currentMessage);
139
+ s.stop(finalMsg, code);
140
+ },
141
+ error(msg) {
142
+ stopTick();
143
+ s.error(msg != null ? render(msg) : render(currentMessage));
144
+ },
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Multi-step spinner where every step shows its own elapsed-time counter.
150
+ * Use for long sequential flows like Firebase project creation so the user
151
+ * can tell which steps are slow.
152
+ */
153
+ function makeTimedStepper() {
154
+ let current = null;
155
+ let currentMsg = '';
156
+ return {
157
+ next(text) {
158
+ if (current) current.stop(currentMsg);
159
+ current = timedSpinner();
160
+ currentMsg = text;
161
+ current.start(text);
162
+ },
163
+ update(text) {
164
+ if (current) {
165
+ currentMsg = text;
166
+ current.message(text);
167
+ }
168
+ },
169
+ succeed(text) {
170
+ if (current) {
171
+ current.stop(text || currentMsg);
172
+ current = null;
173
+ currentMsg = '';
174
+ }
175
+ },
176
+ fail(text) {
177
+ if (current) {
178
+ current.error(text || currentMsg);
179
+ current = null;
180
+ currentMsg = '';
181
+ }
182
+ },
183
+ warn(text) {
184
+ if (current) {
185
+ current.stop(`⚠ ${text || currentMsg}`);
186
+ current = null;
187
+ currentMsg = '';
188
+ }
189
+ },
190
+ stop() {
191
+ if (current) {
192
+ current.stop(currentMsg);
193
+ current = null;
194
+ currentMsg = '';
195
+ }
196
+ },
197
+ };
198
+ }
199
+
88
200
  /**
89
201
  * Multi-step spinner: each .next(text) succeeds the previous step
90
202
  * with the previous message, then starts a new step with `text`.
@@ -166,7 +278,9 @@ module.exports = {
166
278
  note,
167
279
  cancel,
168
280
  spinner,
281
+ timedSpinner,
169
282
  makeStepper,
283
+ makeTimedStepper,
170
284
  taskLog,
171
285
  progress,
172
286
  log,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -51,7 +51,6 @@
51
51
  "fs-extra": "^11.2.0",
52
52
  "gradient-string": "^1.2.0",
53
53
  "kleur": "^4.1.5",
54
- "ora": "^8.0.1",
55
54
  "prompts": "^2.4.2",
56
55
  "yaml": "^2.4.2"
57
56
  }
@@ -1,4 +1,4 @@
1
- # AppFirebase
1
+ # Kasy App
2
2
 
3
3
  Flutter app with Firebase backend — generated by kasy.
4
4
 
@@ -1,4 +1,4 @@
1
- # AppFirebase
1
+ # Kasy App
2
2
 
3
3
  App Flutter con backend Firebase — generado por kasy.
4
4
 
@@ -1,4 +1,4 @@
1
- # AppFirebase
1
+ # Kasy App
2
2
 
3
3
  Flutter app com backend Firebase — gerado pelo kasy.
4
4
 
@@ -76,5 +76,14 @@ flutter {
76
76
  dependencies {
77
77
  coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
78
78
  implementation("com.google.android.gms:play-services-ads-identifier:18.1.0")
79
- implementation("androidx.glance:glance-appwidget:1.1.1")
79
+ // strictly() blocks Gradle from upgrading via transitive resolution —
80
+ // the home_widget plugin declares `1.+` and would otherwise pick the
81
+ // latest alpha (1.3.0-alpha01 at time of writing), which requires
82
+ // compileSdk 37 + AGP 9.x and breaks the build.
83
+ implementation("androidx.glance:glance-appwidget") {
84
+ version {
85
+ strictly("1.1.1")
86
+ }
87
+ }
88
+ implementation("androidx.appcompat:appcompat:1.7.0")
80
89
  }
@@ -16,7 +16,7 @@
16
16
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
17
17
 
18
18
  <application
19
- android:label="AppFirebase"
19
+ android:label="Kasy App"
20
20
  android:name="${applicationName}"
21
21
  android:icon="@mipmap/ic_launcher"
22
22
  android:localeConfig="@xml/locales_config">
@@ -1,5 +1,8 @@
1
1
  package com.aicrus.firebase.kit
2
-
2
+
3
+ import android.content.Context
4
+ import android.os.Bundle
5
+ import androidx.appcompat.app.AppCompatDelegate
3
6
  import com.google.android.gms.ads.identifier.AdvertisingIdClient
4
7
  import io.flutter.embedding.android.FlutterActivity
5
8
  import io.flutter.embedding.engine.FlutterEngine
@@ -12,6 +15,27 @@ import kotlinx.coroutines.withContext
12
15
  class MainActivity : FlutterActivity() {
13
16
  private val CHANNEL = "kasy_kit/advertising_id"
14
17
 
18
+ override fun onCreate(savedInstanceState: Bundle?) {
19
+ applySavedThemeMode()
20
+ super.onCreate(savedInstanceState)
21
+ }
22
+
23
+ // Forces the night mode to match the user's saved theme preference (read
24
+ // from `shared_preferences`) so the native splash drawable selection
25
+ // (drawable-night vs drawable) follows the in-app choice, not just the OS.
26
+ private fun applySavedThemeMode() {
27
+ val prefs = applicationContext.getSharedPreferences(
28
+ "FlutterSharedPreferences",
29
+ Context.MODE_PRIVATE,
30
+ )
31
+ val mode = when (prefs.getString("flutter.themeMode", null)) {
32
+ "dark" -> AppCompatDelegate.MODE_NIGHT_YES
33
+ "light" -> AppCompatDelegate.MODE_NIGHT_NO
34
+ else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
35
+ }
36
+ AppCompatDelegate.setDefaultNightMode(mode)
37
+ }
38
+
15
39
  override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
16
40
  super.configureFlutterEngine(flutterEngine)
17
41
 
@@ -1,32 +1,50 @@
1
1
  package com.aicrus.firebase.kit
2
2
 
3
3
  import android.content.Context
4
+ import android.content.Intent
4
5
  import androidx.compose.runtime.Composable
5
6
  import androidx.compose.ui.graphics.Color
6
7
  import androidx.compose.ui.unit.dp
7
8
  import androidx.compose.ui.unit.sp
8
9
  import androidx.glance.GlanceId
9
10
  import androidx.glance.GlanceModifier
11
+ import androidx.glance.Image
12
+ import androidx.glance.ImageProvider
13
+ import androidx.glance.LocalSize
14
+ import androidx.glance.action.clickable
10
15
  import androidx.glance.appwidget.GlanceAppWidget
16
+ import androidx.glance.appwidget.SizeMode
17
+ import androidx.glance.appwidget.action.actionStartActivity
11
18
  import androidx.glance.appwidget.provideContent
12
19
  import androidx.glance.background
13
20
  import androidx.glance.currentState
14
21
  import androidx.glance.layout.Alignment
15
22
  import androidx.glance.layout.Box
16
23
  import androidx.glance.layout.Column
24
+ import androidx.glance.layout.ContentScale
25
+ import androidx.glance.layout.Row
17
26
  import androidx.glance.layout.Spacer
18
27
  import androidx.glance.layout.fillMaxSize
28
+ import androidx.glance.layout.fillMaxWidth
19
29
  import androidx.glance.layout.padding
30
+ import androidx.glance.layout.size
20
31
  import androidx.glance.state.GlanceStateDefinition
32
+ import androidx.glance.text.FontStyle
21
33
  import androidx.glance.text.FontWeight
22
34
  import androidx.glance.text.Text
23
35
  import androidx.glance.text.TextStyle
24
36
  import androidx.glance.unit.ColorProvider
25
37
  import es.antonborri.home_widget.HomeWidgetGlanceState
26
38
  import es.antonborri.home_widget.HomeWidgetGlanceStateDefinition
39
+ import java.util.Calendar
40
+ import java.util.Locale
27
41
 
28
42
  class MyWidgetWidget : GlanceAppWidget() {
29
43
 
44
+ // Recompose when the user resizes the widget so the layout can adapt
45
+ // between small (no "+", no quote) and large (everything visible).
46
+ override val sizeMode: SizeMode = SizeMode.Exact
47
+
30
48
  override val stateDefinition: GlanceStateDefinition<*>?
31
49
  get() = HomeWidgetGlanceStateDefinition()
32
50
 
@@ -39,22 +57,49 @@ class MyWidgetWidget : GlanceAppWidget() {
39
57
  @Composable
40
58
  private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) {
41
59
  val prefs = currentState.preferences
42
- val greeting = prefs.getString("greeting", "") ?: ""
43
- val title = prefs.getString("title", "") ?: ""
60
+ val storedGreeting = prefs.getString("greeting", "") ?: ""
61
+ val storedTitle = prefs.getString("title", "") ?: ""
44
62
  val planText = prefs.getString("planText", "") ?: ""
45
63
  val isPro = prefs.getString("isPro", "false") == "true"
64
+ val quote = prefs.getString("quote", "") ?: ""
65
+
66
+ // Time/locale-based fallback used when Flutter has not pushed data yet —
67
+ // first install before the app opens. Keeps the widget from rendering
68
+ // blank in the gallery preview.
69
+ val defaults = defaultStrings()
70
+ val greeting = storedGreeting.ifEmpty { defaults.greeting }
71
+ val title = storedTitle.ifEmpty { defaults.hello }
72
+
73
+ val size = LocalSize.current
74
+ val isSmall = size.width < 200.dp
75
+ // Heuristic: tall enough to fit greeting + title + quote without crowding.
76
+ val isLarge = size.height >= 280.dp
46
77
 
47
- val bgColor = Color(red = 0.08f, green = 0.03f, blue = 0.16f)
48
78
  val white = Color.White
49
79
  val whiteSubtle = Color(red = 1f, green = 1f, blue = 1f, alpha = 0.55f)
80
+ val whiteQuote = Color(red = 1f, green = 1f, blue = 1f, alpha = 0.65f)
50
81
  val gold = Color(red = 1f, green = 0.84f, blue = 0f)
51
- val whiteVerySubtle = Color(red = 1f, green = 1f, blue = 1f, alpha = 0.40f)
82
+ val whiteVerySubtle = Color(red = 1f, green = 1f, blue = 1f, alpha = 0.45f)
52
83
 
84
+ // The gradient lives in its own Image at the bottom of the stack rather
85
+ // than as `.background(ImageProvider(...))`, because in some Glance
86
+ // versions the latter ends up rendered ABOVE the content — the widget
87
+ // shows just the gradient, no text. The explicit Image+Column layering
88
+ // here is deterministic.
53
89
  Box(
54
- modifier = GlanceModifier.fillMaxSize().background(bgColor).padding(16.dp),
55
- contentAlignment = Alignment.TopStart,
90
+ modifier = GlanceModifier
91
+ .fillMaxSize()
92
+ .clickable(actionStartActivity(launchAppIntent(context))),
56
93
  ) {
57
- Column(modifier = GlanceModifier.fillMaxSize()) {
94
+ Image(
95
+ provider = ImageProvider(R.drawable.widget_gradient_inner),
96
+ contentDescription = null,
97
+ contentScale = ContentScale.FillBounds,
98
+ modifier = GlanceModifier.fillMaxSize(),
99
+ )
100
+ Column(
101
+ modifier = GlanceModifier.fillMaxSize().padding(16.dp),
102
+ ) {
58
103
  Text(
59
104
  text = greeting,
60
105
  style = TextStyle(
@@ -67,21 +112,125 @@ class MyWidgetWidget : GlanceAppWidget() {
67
112
  text = title,
68
113
  style = TextStyle(
69
114
  color = ColorProvider(white),
70
- fontSize = 22.sp,
115
+ fontSize = if (isSmall) 20.sp else 24.sp,
71
116
  fontWeight = FontWeight.Bold,
72
117
  ),
73
118
  modifier = GlanceModifier.padding(top = 4.dp),
74
119
  )
120
+ if (isLarge && quote.isNotEmpty()) {
121
+ Text(
122
+ text = quote,
123
+ style = TextStyle(
124
+ color = ColorProvider(whiteQuote),
125
+ fontSize = 15.sp,
126
+ fontWeight = FontWeight.Normal,
127
+ fontStyle = FontStyle.Italic,
128
+ ),
129
+ maxLines = 4,
130
+ modifier = GlanceModifier.padding(top = 12.dp),
131
+ )
132
+ }
75
133
  Spacer(modifier = GlanceModifier.defaultWeight())
134
+ Row(
135
+ modifier = GlanceModifier.fillMaxWidth(),
136
+ verticalAlignment = Alignment.CenterVertically,
137
+ ) {
138
+ // Empty planText hides the pill (used in logged-out state).
139
+ if (planText.isNotEmpty()) {
140
+ PlanPill(
141
+ isPro = isPro,
142
+ planText = planText,
143
+ gold = gold,
144
+ whiteVerySubtle = whiteVerySubtle,
145
+ )
146
+ }
147
+ // Small intentionally drops the "+" so the layout breathes —
148
+ // the pill sits flush left like the original design.
149
+ if (!isSmall) {
150
+ Spacer(modifier = GlanceModifier.defaultWeight())
151
+ Image(
152
+ provider = ImageProvider(R.drawable.widget_add_button),
153
+ contentDescription = "Add",
154
+ modifier = GlanceModifier
155
+ .size(34.dp)
156
+ .clickable(actionStartActivity(launchAppIntent(context))),
157
+ )
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ @Composable
165
+ private fun PlanPill(
166
+ isPro: Boolean,
167
+ planText: String,
168
+ gold: Color,
169
+ whiteVerySubtle: Color,
170
+ ) {
171
+ if (isPro) {
172
+ Box(
173
+ modifier = GlanceModifier
174
+ .background(ImageProvider(R.drawable.widget_pro_pill_bg))
175
+ .padding(horizontal = 10.dp, vertical = 5.dp),
176
+ ) {
177
+ Text(
178
+ text = "⭐ $planText",
179
+ style = TextStyle(
180
+ color = ColorProvider(gold),
181
+ fontSize = 11.sp,
182
+ fontWeight = FontWeight.Bold,
183
+ ),
184
+ )
185
+ }
186
+ } else {
187
+ Box(
188
+ modifier = GlanceModifier
189
+ .background(ImageProvider(R.drawable.widget_plan_pill_bg))
190
+ .padding(horizontal = 10.dp, vertical = 5.dp),
191
+ ) {
76
192
  Text(
77
- text = if (isPro) "⭐ $planText" else planText,
193
+ text = planText,
78
194
  style = TextStyle(
79
- color = ColorProvider(if (isPro) gold else whiteVerySubtle),
195
+ color = ColorProvider(whiteVerySubtle),
80
196
  fontSize = 11.sp,
81
- fontWeight = if (isPro) FontWeight.Bold else FontWeight.Medium,
197
+ fontWeight = FontWeight.Medium,
82
198
  ),
83
199
  )
84
200
  }
85
201
  }
86
202
  }
203
+
204
+ private data class DefaultStrings(val greeting: String, val hello: String)
205
+
206
+ private fun defaultStrings(): DefaultStrings {
207
+ val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
208
+ val lang = Locale.getDefault().language
209
+ val morning: String; val afternoon: String; val evening: String; val hello: String
210
+ when (lang) {
211
+ "pt" -> { morning = "Bom dia"; afternoon = "Boa tarde"; evening = "Boa noite"; hello = "Olá!" }
212
+ "es" -> { morning = "Buenos días"; afternoon = "Buenas tardes"; evening = "Buenas noches"; hello = "¡Hola!" }
213
+ else -> { morning = "Good morning"; afternoon = "Good afternoon"; evening = "Good evening"; hello = "Hi there!" }
214
+ }
215
+ val greeting = when {
216
+ hour < 12 -> morning
217
+ hour < 18 -> afternoon
218
+ else -> evening
219
+ }
220
+ return DefaultStrings(greeting, hello)
221
+ }
222
+
223
+ /// Builds the same Intent the launcher would fire when the user taps the
224
+ /// app icon: ACTION_MAIN + CATEGORY_LAUNCHER. Without these flags, the
225
+ /// activity receives an empty Intent and go_router lands on the
226
+ /// errorBuilder (the "404 - Page not found" screen).
227
+ private fun launchAppIntent(context: Context): Intent {
228
+ return Intent(context, MainActivity::class.java).apply {
229
+ action = Intent.ACTION_MAIN
230
+ addCategory(Intent.CATEGORY_LAUNCHER)
231
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or
232
+ Intent.FLAG_ACTIVITY_CLEAR_TOP or
233
+ Intent.FLAG_ACTIVITY_SINGLE_TOP
234
+ }
235
+ }
87
236
  }
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
3
+ android:width="34dp"
4
+ android:height="34dp"
5
+ android:viewportWidth="34"
6
+ android:viewportHeight="34">
7
+ <path
8
+ android:pathData="M17,0 A17,17 0 1,0 17,34 A17,17 0 1,0 17,0 Z"
9
+ android:fillColor="#2EFFFFFF"/>
10
+ <path
11
+ android:pathData="M17,9 L17,25 M9,17 L25,17"
12
+ android:strokeColor="#FFFFFFFF"
13
+ android:strokeWidth="2.5"
14
+ android:strokeLineCap="round"/>
15
+ </vector>
@@ -0,0 +1,9 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
3
+ <gradient
4
+ android:angle="315"
5
+ android:startColor="#FF140829"
6
+ android:endColor="#FF33176B"
7
+ android:type="linear" />
8
+ <corners android:radius="24dp" />
9
+ </shape>
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Same gradient as widget_gradient_bg, but without rounded corners.
3
+ Used as the background of the Glance widget itself — the system
4
+ already clips the widget with the OS-provided corner radius, so
5
+ adding corners here would cause a visible double-radius edge. -->
6
+ <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
7
+ <gradient
8
+ android:angle="315"
9
+ android:startColor="#FF140829"
10
+ android:endColor="#FF33176B"
11
+ android:type="linear" />
12
+ </shape>
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
3
+ <corners android:radius="999dp"/>
4
+ <solid android:color="#14FFFFFF"/>
5
+ </shape>
@@ -0,0 +1,17 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Fallback preview shown in the widget gallery on launchers that don't
3
+ honor android:previewLayout (Android < 12, or some OEM launchers).
4
+ Layer list: rounded gradient + a hint star to suggest the PRO badge.
5
+ The actual widget overrides this once placed. -->
6
+ <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
7
+ <item>
8
+ <shape android:shape="rectangle">
9
+ <gradient
10
+ android:angle="315"
11
+ android:startColor="#FF140829"
12
+ android:endColor="#FF33176B"
13
+ android:type="linear" />
14
+ <corners android:radius="24dp" />
15
+ </shape>
16
+ </item>
17
+ </layer-list>
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
3
+ <corners android:radius="999dp"/>
4
+ <solid android:color="#2EFFD700"/>
5
+ </shape>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Brief-flash layout shown for ~200ms while Glance composes the real
3
+ widget. Just the gradient — no text — so the user never sees the
4
+ gray default loading layout from the home_widget library. -->
5
+ <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
6
+ android:layout_width="match_parent"
7
+ android:layout_height="match_parent"
8
+ android:background="@drawable/widget_gradient_bg" />
@@ -0,0 +1,46 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Static preview shown in the Android widget gallery (Android 12+).
3
+ Mirrors the real widget layout so the user can see what they're
4
+ adding before they drop it on the home screen. The Glance widget
5
+ replaces this once placed. -->
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:padding="16dp">
12
+
13
+ <TextView
14
+ android:layout_width="wrap_content"
15
+ android:layout_height="wrap_content"
16
+ android:fontFamily="sans-serif-medium"
17
+ android:text="Boa noite"
18
+ android:textColor="#8CFFFFFF"
19
+ android:textSize="11sp" />
20
+
21
+ <TextView
22
+ android:layout_width="wrap_content"
23
+ android:layout_height="wrap_content"
24
+ android:layout_marginTop="4dp"
25
+ android:text="Olá!"
26
+ android:textColor="#FFFFFFFF"
27
+ android:textSize="24sp"
28
+ android:textStyle="bold" />
29
+
30
+ <Space
31
+ android:layout_width="match_parent"
32
+ android:layout_height="0dp"
33
+ android:layout_weight="1" />
34
+
35
+ <TextView
36
+ android:layout_width="wrap_content"
37
+ android:layout_height="wrap_content"
38
+ android:background="@drawable/widget_pro_pill_bg"
39
+ android:paddingHorizontal="10dp"
40
+ android:paddingVertical="5dp"
41
+ android:text="⭐ PRO"
42
+ android:textColor="#FFFFD700"
43
+ android:textSize="11sp"
44
+ android:textStyle="bold" />
45
+
46
+ </LinearLayout>
@@ -1,11 +1,17 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
2
  <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
3
- android:initialLayout="@layout/glance_default_loading_layout"
3
+ android:initialLayout="@layout/widget_loading"
4
+ android:previewLayout="@layout/widget_preview"
5
+ android:previewImage="@drawable/widget_preview_image"
4
6
  android:minWidth="180dp"
5
7
  android:minHeight="180dp"
8
+ android:minResizeWidth="120dp"
9
+ android:minResizeHeight="120dp"
10
+ android:maxResizeWidth="320dp"
11
+ android:maxResizeHeight="320dp"
6
12
  android:resizeMode="horizontal|vertical"
7
- android:updatePeriodMillis="900000"
13
+ android:updatePeriodMillis="0"
8
14
  android:description="@string/widget_my_widget_description"
9
15
  android:targetCellWidth="2"
10
16
  android:targetCellHeight="2"
11
- />
17
+ android:widgetCategory="home_screen|keyguard" />
@@ -20,6 +20,16 @@
20
20
  "queryScope": "COLLECTION_GROUP"
21
21
  }
22
22
  ]
23
+ },
24
+ {
25
+ "collectionGroup": "devices",
26
+ "fieldPath": "token",
27
+ "indexes": [
28
+ {
29
+ "order": "ASCENDING",
30
+ "queryScope": "COLLECTION_GROUP"
31
+ }
32
+ ]
23
33
  }
24
34
  ]
25
35
  }