kasy-cli 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/bin/kasy.js +140 -12
  2. package/lib/commands/add.js +2 -2
  3. package/lib/commands/codemagic.js +11 -4
  4. package/lib/commands/deploy.js +3 -3
  5. package/lib/commands/favicon.js +115 -0
  6. package/lib/commands/icon.js +143 -0
  7. package/lib/commands/ios.js +28 -7
  8. package/lib/commands/new.js +8 -20
  9. package/lib/commands/remove.js +1 -1
  10. package/lib/commands/reset.js +385 -0
  11. package/lib/commands/run.js +24 -17
  12. package/lib/commands/splash.js +14 -4
  13. package/lib/commands/update.js +1 -1
  14. package/lib/scaffold/backends/api/patch/README.md +1 -1
  15. package/lib/scaffold/backends/api/patch/android/app/src/main/AndroidManifest.xml +1 -1
  16. package/lib/scaffold/backends/api/patch/lib/features/notifications/api/device_api.dart +53 -0
  17. package/lib/scaffold/backends/api/pubspec.yaml.tpl +11 -1
  18. package/lib/scaffold/backends/firebase/tokens.js +2 -2
  19. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -2
  20. package/lib/scaffold/backends/supabase/migrations/20240101000011_dedupe_device_tokens.sql +34 -0
  21. package/lib/scaffold/backends/supabase/patch/README.md +1 -1
  22. package/lib/scaffold/backends/supabase/patch/android/app/src/main/AndroidManifest.xml +1 -1
  23. package/lib/scaffold/backends/supabase/patch/lib/features/notifications/api/device_api.dart +43 -0
  24. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +11 -1
  25. package/lib/utils/apple-release.js +115 -16
  26. package/lib/utils/checks.js +45 -107
  27. package/lib/utils/debug.js +75 -0
  28. package/lib/utils/flutter-run.js +173 -0
  29. package/lib/utils/friendly-error.js +91 -0
  30. package/lib/utils/i18n/messages-en.js +970 -0
  31. package/lib/utils/i18n/messages-es.js +968 -0
  32. package/lib/utils/i18n/messages-pt.js +968 -0
  33. package/lib/utils/i18n.js +21 -2483
  34. package/lib/utils/mobile-identity.js +35 -0
  35. package/lib/utils/png-padding.js +120 -0
  36. package/lib/utils/ui.js +114 -0
  37. package/package.json +8 -4
  38. package/templates/firebase/README.en.md +1 -1
  39. package/templates/firebase/README.es.md +1 -1
  40. package/templates/firebase/README.md +1 -1
  41. package/templates/firebase/android/app/build.gradle.kts +10 -1
  42. package/templates/firebase/android/app/src/main/AndroidManifest.xml +1 -1
  43. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MainActivity.kt +25 -1
  44. package/templates/firebase/android/app/src/main/kotlin/com/aicrus/firebase/kit/MyWidget.kt +161 -11
  45. package/templates/firebase/android/app/src/main/res/drawable/widget_add_button.xml +15 -0
  46. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_bg.xml +9 -0
  47. package/templates/firebase/android/app/src/main/res/drawable/widget_gradient_inner.xml +12 -0
  48. package/templates/firebase/android/app/src/main/res/drawable/widget_plan_pill_bg.xml +5 -0
  49. package/templates/firebase/android/app/src/main/res/drawable/widget_preview_image.xml +17 -0
  50. package/templates/firebase/android/app/src/main/res/drawable/widget_pro_pill_bg.xml +5 -0
  51. package/templates/firebase/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  52. package/templates/firebase/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png +0 -0
  53. package/templates/firebase/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png +0 -0
  54. package/templates/firebase/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  55. package/templates/firebase/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png +0 -0
  56. package/templates/firebase/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png +0 -0
  57. package/templates/firebase/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  58. package/templates/firebase/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  59. package/templates/firebase/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  60. package/templates/firebase/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  61. package/templates/firebase/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  62. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  63. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png +0 -0
  64. package/templates/firebase/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png +0 -0
  65. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  66. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png +0 -0
  67. package/templates/firebase/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png +0 -0
  68. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  69. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png +0 -0
  70. package/templates/firebase/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png +0 -0
  71. package/templates/firebase/android/app/src/main/res/layout/widget_loading.xml +8 -0
  72. package/templates/firebase/android/app/src/main/res/layout/widget_preview.xml +53 -0
  73. package/templates/firebase/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +9 -0
  74. package/templates/firebase/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  75. package/templates/firebase/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  76. package/templates/firebase/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  77. package/templates/firebase/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  78. package/templates/firebase/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  79. package/templates/firebase/android/app/src/main/res/xml/mywidget_info.xml +9 -3
  80. package/templates/firebase/assets/images/favicon.png +0 -0
  81. package/templates/firebase/assets/images/icon.png +0 -0
  82. package/templates/firebase/assets/images/icon_android.png +0 -0
  83. package/templates/firebase/assets/images/icon_foreground_empty.png +0 -0
  84. package/templates/firebase/assets/images/splash_logo_dark_android12.png +0 -0
  85. package/templates/firebase/assets/images/splash_logo_light_android12.png +0 -0
  86. package/templates/firebase/firestore.indexes.json +10 -0
  87. package/templates/firebase/functions/src/core/data/entities/user_device_entity.ts +3 -0
  88. package/templates/firebase/functions/src/core/data/repositories/user_device_repository.ts +17 -1
  89. package/templates/firebase/functions/src/index.ts +1 -0
  90. package/templates/firebase/functions/src/notifications/device_triggers.ts +58 -0
  91. package/templates/firebase/ios/HomeWidgetExtension/MyWidget.swift +116 -33
  92. package/templates/firebase/ios/Runner/AppDelegate.swift +17 -1
  93. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png +0 -0
  94. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png +0 -0
  95. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png +0 -0
  96. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png +0 -0
  97. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png +0 -0
  98. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png +0 -0
  99. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png +0 -0
  100. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png +0 -0
  101. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png +0 -0
  102. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png +0 -0
  103. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png +0 -0
  104. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png +0 -0
  105. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png +0 -0
  106. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png +0 -0
  107. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png +0 -0
  108. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png +0 -0
  109. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png +0 -0
  110. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png +0 -0
  111. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png +0 -0
  112. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png +0 -0
  113. package/templates/firebase/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png +0 -0
  114. package/templates/firebase/ios/Runner/Info.plist +2 -2
  115. package/templates/firebase/ios/Runner/es.lproj/InfoPlist.strings +1 -1
  116. package/templates/firebase/ios/Runner/pt-BR.lproj/InfoPlist.strings +1 -1
  117. package/templates/firebase/ios/Runner/pt.lproj/InfoPlist.strings +1 -1
  118. package/templates/firebase/lib/components/components.dart +1 -0
  119. package/templates/firebase/lib/components/kasy_avatar.dart +88 -57
  120. package/templates/firebase/lib/components/kasy_avatar_presets.dart +116 -74
  121. package/templates/firebase/lib/components/kasy_button.dart +8 -0
  122. package/templates/firebase/lib/components/kasy_tabs.dart +431 -0
  123. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +18 -0
  124. package/templates/firebase/lib/core/home_widgets/home_widget_mywidget_service.dart +73 -19
  125. package/templates/firebase/lib/core/home_widgets/home_widget_service.dart +22 -8
  126. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +16 -4
  127. package/templates/firebase/lib/core/states/components/maybeshow_component.dart +4 -8
  128. package/templates/firebase/lib/core/states/user_state_notifier.dart +13 -1
  129. package/templates/firebase/lib/features/home/home_components_page.dart +1 -1
  130. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +316 -93
  131. package/templates/firebase/lib/features/home/home_page.dart +0 -6
  132. package/templates/firebase/lib/features/notifications/api/device_api.dart +57 -0
  133. package/templates/firebase/lib/features/notifications/providers/models/notification.dart +11 -1
  134. package/templates/firebase/lib/features/notifications/repositories/device_repository.dart +9 -0
  135. package/templates/firebase/lib/features/notifications/repositories/notifications_repository.dart +1 -4
  136. package/templates/firebase/lib/features/notifications/shared/att_permission.dart +28 -8
  137. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +16 -1
  138. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +44 -11
  139. package/templates/firebase/lib/features/settings/ui/components/admin/admin_bottom_sheet.dart +31 -29
  140. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +21 -5
  141. package/templates/firebase/lib/i18n/en.i18n.json +4 -1
  142. package/templates/firebase/lib/i18n/es.i18n.json +4 -1
  143. package/templates/firebase/lib/i18n/pt.i18n.json +4 -1
  144. package/templates/firebase/pubspec.yaml +10 -3
  145. package/templates/firebase/test/features/notifications/data/device_api_fake.dart +9 -0
  146. package/templates/firebase/web/favicon.png +0 -0
  147. package/templates/firebase/web/icons/Icon-192.png +0 -0
  148. package/templates/firebase/web/icons/Icon-512.png +0 -0
  149. package/templates/firebase/web/icons/Icon-maskable-192.png +0 -0
  150. package/templates/firebase/web/icons/Icon-maskable-512.png +0 -0
  151. package/templates/firebase/web/index.html +9 -0
  152. package/templates/firebase/web/manifest.json +3 -3
  153. package/templates/firebase/assets/images/app_icon.png +0 -0
  154. package/templates/firebase/assets/images/onboarding/img1.jpg +0 -0
  155. package/templates/firebase/assets/images/onboarding/onboarding.png +0 -0
  156. package/templates/firebase/lib/core/states/components/maybe_ask_biometric_setup.dart +0 -88
  157. package/templates/firebase/lib/features/notifications/shared/notification_permission_bottom_sheet.dart +0 -144
@@ -0,0 +1,431 @@
1
+ import 'package:flutter/material.dart';
2
+ import 'package:kasy_kit/core/theme/theme.dart';
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // Data model
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+
8
+ /// Data model for a single tab item.
9
+ class KasyTabItem {
10
+ final String label;
11
+ final IconData? icon;
12
+ final bool enabled;
13
+
14
+ const KasyTabItem({
15
+ required this.label,
16
+ this.icon,
17
+ this.enabled = true,
18
+ });
19
+ }
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Enums
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ /// Visual style for [KasyTabs].
26
+ enum KasyTabsVariant {
27
+ /// Sliding white pill on a gray container.
28
+ primary,
29
+
30
+ /// Blue underline indicator with a bottom divider.
31
+ secondary,
32
+ }
33
+
34
+ /// Sizing mode for [KasyTabs].
35
+ enum KasyTabsMode {
36
+ /// Wraps content (intrinsic width tabs).
37
+ hug,
38
+
39
+ /// Each tab stretches to fill the available width equally.
40
+ fill,
41
+ }
42
+
43
+ // ─────────────────────────────────────────────────────────────────────────────
44
+ // KasyTabs
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+
47
+ /// Animated tab bar with primary (pill) and secondary (underline) variants.
48
+ ///
49
+ /// Usage:
50
+ /// ```dart
51
+ /// KasyTabs(
52
+ /// tabs: ['General', 'Appearance', 'Notifications'],
53
+ /// selectedIndex: _index,
54
+ /// onTabSelected: (i) => setState(() => _index = i),
55
+ /// )
56
+ /// ```
57
+ ///
58
+ /// Supports [KasyTabItem] for per-tab icons and disabled state. The short-form
59
+ /// constructor accepts plain [String] labels.
60
+ class KasyTabs extends StatefulWidget {
61
+ /// Plain-string convenience constructor.
62
+ ///
63
+ /// Converts each string to a [KasyTabItem] with default settings.
64
+ KasyTabs({
65
+ super.key,
66
+ required List<String> tabs,
67
+ required this.selectedIndex,
68
+ required this.onTabSelected,
69
+ this.variant = KasyTabsVariant.primary,
70
+ this.mode = KasyTabsMode.hug,
71
+ }) : items = tabs.map((l) => KasyTabItem(label: l)).toList();
72
+
73
+ /// Full constructor accepting [KasyTabItem] instances (supports icons/disabled).
74
+ const KasyTabs.items({
75
+ super.key,
76
+ required this.items,
77
+ required this.selectedIndex,
78
+ required this.onTabSelected,
79
+ this.variant = KasyTabsVariant.primary,
80
+ this.mode = KasyTabsMode.hug,
81
+ });
82
+
83
+ final List<KasyTabItem> items;
84
+
85
+ /// Index of the currently selected tab.
86
+ final int selectedIndex;
87
+
88
+ /// Called when the user taps an enabled tab.
89
+ final ValueChanged<int> onTabSelected;
90
+
91
+ final KasyTabsVariant variant;
92
+ final KasyTabsMode mode;
93
+
94
+ @override
95
+ State<KasyTabs> createState() => _KasyTabsState();
96
+ }
97
+
98
+ class _KasyTabsState extends State<KasyTabs> {
99
+ // One GlobalKey per tab to measure position/size after layout.
100
+ late List<GlobalKey> _keys;
101
+
102
+ // Indicator geometry (left offset, width) resolved from measured keys.
103
+ double _indicatorLeft = 0;
104
+ double _indicatorWidth = 0;
105
+
106
+ // Whether we have a valid measurement yet.
107
+ bool _measured = false;
108
+
109
+ @override
110
+ void initState() {
111
+ super.initState();
112
+ _keys = List.generate(
113
+ widget.items.length,
114
+ (_) => GlobalKey(),
115
+ );
116
+ WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
117
+ }
118
+
119
+ @override
120
+ void didUpdateWidget(KasyTabs old) {
121
+ super.didUpdateWidget(old);
122
+ // Rebuild keys list if tab count changes.
123
+ if (old.items.length != widget.items.length) {
124
+ _keys = List.generate(
125
+ widget.items.length,
126
+ (_) => GlobalKey(),
127
+ );
128
+ _measured = false;
129
+ }
130
+ // Re-measure whenever selected index changes or keys were rebuilt.
131
+ WidgetsBinding.instance.addPostFrameCallback((_) => _measure());
132
+ }
133
+
134
+ void _measure() {
135
+ if (!mounted) return;
136
+ if (widget.items.isEmpty) return;
137
+
138
+ final int clampedIndex = widget.selectedIndex.clamp(
139
+ 0,
140
+ widget.items.length - 1,
141
+ );
142
+
143
+ final RenderBox? containerBox =
144
+ context.findRenderObject() as RenderBox?;
145
+ if (containerBox == null) return;
146
+
147
+ final GlobalKey key = _keys[clampedIndex];
148
+ final RenderBox? tabBox =
149
+ key.currentContext?.findRenderObject() as RenderBox?;
150
+ if (tabBox == null) return;
151
+
152
+ final Offset tabOffset = tabBox.localToGlobal(
153
+ Offset.zero,
154
+ ancestor: containerBox,
155
+ );
156
+
157
+ final double newLeft = tabOffset.dx;
158
+ final double newWidth = tabBox.size.width;
159
+
160
+ if (!_measured ||
161
+ (_indicatorLeft - newLeft).abs() > 0.1 ||
162
+ (_indicatorWidth - newWidth).abs() > 0.1) {
163
+ setState(() {
164
+ _indicatorLeft = newLeft;
165
+ _indicatorWidth = newWidth;
166
+ _measured = true;
167
+ });
168
+ }
169
+ }
170
+
171
+ @override
172
+ Widget build(BuildContext context) {
173
+ if (widget.items.isEmpty) return const SizedBox.shrink();
174
+
175
+ return widget.variant == KasyTabsVariant.primary
176
+ ? _buildPrimary(context)
177
+ : _buildSecondary(context);
178
+ }
179
+
180
+ // ── Primary (pill indicator) ───────────────────────────────────────────────
181
+
182
+ Widget _buildPrimary(BuildContext context) {
183
+ final KasyColors c = context.colors;
184
+ final bool isDark = context.isDark;
185
+
186
+ return DecoratedBox(
187
+ decoration: BoxDecoration(
188
+ color: c.avatarFallbackFill,
189
+ borderRadius: BorderRadius.circular(KasyRadius.full),
190
+ ),
191
+ child: Padding(
192
+ padding: const EdgeInsets.all(4),
193
+ child: Stack(
194
+ children: [
195
+ // Animated pill background.
196
+ if (_measured)
197
+ AnimatedPositioned(
198
+ duration: const Duration(milliseconds: 250),
199
+ curve: Curves.easeInOut,
200
+ left: _indicatorLeft,
201
+ top: 0,
202
+ bottom: 0,
203
+ width: _indicatorWidth,
204
+ child: DecoratedBox(
205
+ decoration: BoxDecoration(
206
+ color: c.surface,
207
+ borderRadius: BorderRadius.circular(KasyRadius.full),
208
+ boxShadow: [
209
+ BoxShadow(
210
+ color: Colors.black.withValues(
211
+ alpha: isDark ? 0.18 : 0.08,
212
+ ),
213
+ blurRadius: 8,
214
+ offset: const Offset(0, 2),
215
+ ),
216
+ BoxShadow(
217
+ color: Colors.black.withValues(
218
+ alpha: isDark ? 0.10 : 0.04,
219
+ ),
220
+ spreadRadius: 1,
221
+ ),
222
+ ],
223
+ ),
224
+ ),
225
+ ),
226
+ // Tab labels (on top of the pill).
227
+ Row(
228
+ mainAxisSize: widget.mode == KasyTabsMode.fill
229
+ ? MainAxisSize.max
230
+ : MainAxisSize.min,
231
+ children: List.generate(widget.items.length, (i) {
232
+ final KasyTabItem item = widget.items[i];
233
+ final bool selected = i == widget.selectedIndex;
234
+ return _PrimaryTab(
235
+ key: _keys[i],
236
+ item: item,
237
+ selected: selected,
238
+ expand: widget.mode == KasyTabsMode.fill,
239
+ onTap: item.enabled ? () => widget.onTabSelected(i) : null,
240
+ );
241
+ }),
242
+ ),
243
+ ],
244
+ ),
245
+ ),
246
+ );
247
+ }
248
+
249
+ // ── Secondary (underline indicator) ───────────────────────────────────────
250
+
251
+ Widget _buildSecondary(BuildContext context) {
252
+ final KasyColors c = context.colors;
253
+
254
+ return Stack(
255
+ clipBehavior: Clip.none,
256
+ children: [
257
+ // Full-width bottom divider.
258
+ Positioned(
259
+ left: 0,
260
+ right: 0,
261
+ bottom: 0,
262
+ child: DecoratedBox(
263
+ decoration: BoxDecoration(
264
+ color: c.outline.withValues(alpha: 0.2),
265
+ ),
266
+ child: const SizedBox(height: 1),
267
+ ),
268
+ ),
269
+ // Animated underline indicator.
270
+ if (_measured)
271
+ AnimatedPositioned(
272
+ duration: const Duration(milliseconds: 250),
273
+ curve: Curves.easeInOut,
274
+ left: _indicatorLeft,
275
+ bottom: 0,
276
+ width: _indicatorWidth,
277
+ height: 2,
278
+ child: DecoratedBox(
279
+ decoration: BoxDecoration(
280
+ color: c.primary,
281
+ borderRadius: BorderRadius.circular(1),
282
+ ),
283
+ ),
284
+ ),
285
+ // Tab labels.
286
+ Row(
287
+ mainAxisSize: widget.mode == KasyTabsMode.fill
288
+ ? MainAxisSize.max
289
+ : MainAxisSize.min,
290
+ children: List.generate(widget.items.length, (i) {
291
+ final KasyTabItem item = widget.items[i];
292
+ final bool selected = i == widget.selectedIndex;
293
+ return _SecondaryTab(
294
+ key: _keys[i],
295
+ item: item,
296
+ selected: selected,
297
+ expand: widget.mode == KasyTabsMode.fill,
298
+ onTap: item.enabled ? () => widget.onTabSelected(i) : null,
299
+ );
300
+ }),
301
+ ),
302
+ ],
303
+ );
304
+ }
305
+ }
306
+
307
+ // ─────────────────────────────────────────────────────────────────────────────
308
+ // Internal tab widgets
309
+ // ─────────────────────────────────────────────────────────────────────────────
310
+
311
+ class _PrimaryTab extends StatelessWidget {
312
+ const _PrimaryTab({
313
+ super.key,
314
+ required this.item,
315
+ required this.selected,
316
+ required this.expand,
317
+ required this.onTap,
318
+ });
319
+
320
+ final KasyTabItem item;
321
+ final bool selected;
322
+ final bool expand;
323
+ final VoidCallback? onTap;
324
+
325
+ Widget _tabContent(BuildContext context) {
326
+ final KasyColors c = context.colors;
327
+ final bool disabled = !item.enabled;
328
+
329
+ return GestureDetector(
330
+ onTap: onTap,
331
+ behavior: HitTestBehavior.opaque,
332
+ child: Padding(
333
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
334
+ child: Row(
335
+ mainAxisSize: MainAxisSize.min,
336
+ mainAxisAlignment: MainAxisAlignment.center,
337
+ children: [
338
+ if (item.icon != null) ...[
339
+ Opacity(
340
+ opacity: disabled ? 0.4 : 1.0,
341
+ child: Icon(
342
+ item.icon,
343
+ size: 16,
344
+ color: selected ? c.onSurface : c.muted,
345
+ ),
346
+ ),
347
+ const SizedBox(width: 6),
348
+ ],
349
+ Opacity(
350
+ opacity: disabled ? 0.4 : 1.0,
351
+ child: Text(
352
+ item.label,
353
+ style: context.textTheme.labelLarge?.copyWith(
354
+ color: selected ? c.onSurface : c.muted,
355
+ fontWeight: selected ? FontWeight.w500 : FontWeight.w400,
356
+ ),
357
+ ),
358
+ ),
359
+ ],
360
+ ),
361
+ ),
362
+ );
363
+ }
364
+
365
+ @override
366
+ Widget build(BuildContext context) {
367
+ final Widget inner = _tabContent(context);
368
+ return expand ? Expanded(child: inner) : inner;
369
+ }
370
+ }
371
+
372
+ class _SecondaryTab extends StatelessWidget {
373
+ const _SecondaryTab({
374
+ super.key,
375
+ required this.item,
376
+ required this.selected,
377
+ required this.expand,
378
+ required this.onTap,
379
+ });
380
+
381
+ final KasyTabItem item;
382
+ final bool selected;
383
+ final bool expand;
384
+ final VoidCallback? onTap;
385
+
386
+ Widget _tabContent(BuildContext context) {
387
+ final KasyColors c = context.colors;
388
+ final bool disabled = !item.enabled;
389
+
390
+ return GestureDetector(
391
+ onTap: onTap,
392
+ behavior: HitTestBehavior.opaque,
393
+ child: Padding(
394
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
395
+ child: Row(
396
+ mainAxisSize: MainAxisSize.min,
397
+ mainAxisAlignment: MainAxisAlignment.center,
398
+ children: [
399
+ if (item.icon != null) ...[
400
+ Opacity(
401
+ opacity: disabled ? 0.4 : 1.0,
402
+ child: Icon(
403
+ item.icon,
404
+ size: 16,
405
+ color: selected ? c.onSurface : c.muted,
406
+ ),
407
+ ),
408
+ const SizedBox(width: 6),
409
+ ],
410
+ Opacity(
411
+ opacity: disabled ? 0.4 : 1.0,
412
+ child: Text(
413
+ item.label,
414
+ style: context.textTheme.labelLarge?.copyWith(
415
+ color: selected ? c.onSurface : c.muted,
416
+ fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
417
+ ),
418
+ ),
419
+ ),
420
+ ],
421
+ ),
422
+ ),
423
+ );
424
+ }
425
+
426
+ @override
427
+ Widget build(BuildContext context) {
428
+ final Widget inner = _tabContent(context);
429
+ return expand ? Expanded(child: inner) : inner;
430
+ }
431
+ }
@@ -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,22 @@ 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 via SharedPreferences.apply() which
156
+ // is asynchronous. Glance's HomeWidgetGlanceStateDefinition reads from
157
+ // the same prefs, but a tight saveWidgetData→updateWidget sequence can
158
+ // race the apply() flush — Glance recomposes with the previous values
159
+ // (most visible right after a locale change). Fire two updates spaced
160
+ // out by 400ms each so even slow devices catch the new state.
161
+ if (!kIsWeb && Platform.isAndroid) {
162
+ await Future<void>.delayed(const Duration(milliseconds: 400));
163
+ await HomeWidget.updateWidget(
164
+ name: _androidWidgetName,
165
+ iOSName: _iosWidgetName,
166
+ );
167
+ await Future<void>.delayed(const Duration(milliseconds: 400));
168
+ }
115
169
 
116
170
  await HomeWidget.updateWidget(
117
171
  name: _androidWidgetName,
@@ -1,3 +1,5 @@
1
+ import 'dart:async';
2
+
1
3
  import 'package:background_fetch/background_fetch.dart';
2
4
  import 'package:flutter/foundation.dart';
3
5
  import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -8,14 +10,7 @@ import 'package:kasy_kit/core/initializer/onstart_service.dart';
8
10
  import 'package:logger/logger.dart';
9
11
 
10
12
  final homeWidgetsManagerProvider = Provider<HomeWidgetsManager>(
11
- (ref) {
12
- // Force-build the widget service at app startup so its user-state
13
- // listener attaches and the widget auto-refreshes when subscription
14
- // status or other relevant fields change. Without this read, the
15
- // listener would only attach on first manual update.
16
- ref.read(myWidgetHomeWidgetProvider.notifier);
17
- return HomeWidgetsManager();
18
- },
13
+ (ref) => HomeWidgetsManager(ref),
19
14
  );
20
15
 
21
16
  const String appGroupId = 'group.com.aicrus.firebase.kit';
@@ -26,12 +21,31 @@ const String appGroupId = 'group.com.aicrus.firebase.kit';
26
21
  /// will be used to initialize the home widgets and set the app group id
27
22
  /// Register the background task for the home widgets
28
23
  class HomeWidgetsManager implements OnStartService {
24
+ HomeWidgetsManager(this._ref);
25
+
26
+ final Ref _ref;
27
+
29
28
  @override
30
29
  Future<void> init() async {
31
30
  if (kIsWeb) return;
32
31
  try {
32
+ // Must be set BEFORE any saveWidgetData call, otherwise the data lands
33
+ // in the default UserDefaults suite and the native widget extension
34
+ // (which reads from the app group) sees nothing.
33
35
  await HomeWidget.setAppGroupId(appGroupId);
34
36
 
37
+ // Read the widget notifier so its user-state listener attaches —
38
+ // future state changes (login/logout, subscription) auto-refresh
39
+ // the widget without waiting for the 15-min background tick.
40
+ final myWidget = _ref.read(myWidgetHomeWidgetProvider.notifier);
41
+ // Push initial data so the widget renders something on first install
42
+ // instead of staying blank until the background task fires.
43
+ // Fire-and-forget: we do NOT await here because update() may do a
44
+ // network call (RevenueCat) that could stall app startup and even
45
+ // prevent BackgroundFetch from being configured. setAppGroupId has
46
+ // already completed, so it is safe to fire it off now.
47
+ unawaited(myWidget.update());
48
+
35
49
  final status = await BackgroundFetch.configure(
36
50
  BackgroundFetchConfig(
37
51
  minimumFetchInterval: 15,