kasy-cli 1.31.9 → 1.31.10

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 (25) hide show
  1. package/lib/commands/new.js +7 -10
  2. package/lib/scaffold/CHANGELOG.json +9 -0
  3. package/lib/scaffold/backends/supabase/config.toml +39 -2
  4. package/lib/scaffold/backends/supabase/deploy.js +14 -16
  5. package/lib/scaffold/catalog.js +24 -0
  6. package/package.json +2 -2
  7. package/templates/firebase/assets/images/premium-bg.jpg +0 -0
  8. package/templates/firebase/assets/images/premium-switch-header.png +0 -0
  9. package/templates/firebase/lib/components/kasy_app_bar.dart +107 -1
  10. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +26 -7
  11. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +22 -33
  12. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +119 -0
  13. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +12 -0
  14. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +25 -3
  15. package/templates/firebase/lib/features/home/home_feed.dart +7 -1
  16. package/templates/firebase/lib/features/home/home_page.dart +6 -8
  17. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -0
  18. package/templates/firebase/lib/features/settings/settings_page.dart +26 -0
  19. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +169 -90
  20. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +219 -202
  21. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +67 -30
  22. package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +16 -6
  23. package/templates/firebase/lib/i18n/en.i18n.json +1 -0
  24. package/templates/firebase/lib/i18n/es.i18n.json +1 -0
  25. package/templates/firebase/lib/i18n/pt.i18n.json +1 -0
@@ -533,7 +533,9 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
533
533
  void _onFrameVisibleChanged() {
534
534
  if (!mounted) return;
535
535
  final store = Provider.of<DevicePreviewStore>(context, listen: false);
536
- if (store.data.isFrameVisible != widget.frameVisibleNotifier.value) {
536
+ final data = _readData(store);
537
+ if (data == null) return;
538
+ if (data.isFrameVisible != widget.frameVisibleNotifier.value) {
537
539
  store.toggleFrame();
538
540
  }
539
541
  }
@@ -543,11 +545,31 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
543
545
  void _syncOrientation() {
544
546
  if (!mounted) return;
545
547
  final store = Provider.of<DevicePreviewStore>(context, listen: false);
548
+ final data = _readData(store);
549
+ if (data == null) {
550
+ // DevicePreview initializes asynchronously (it loads saved preferences), so
551
+ // on the first web frame the store can still be uninitialized and reading
552
+ // store.data throws "Not initialized". Retry next frame instead of surfacing
553
+ // a scary (and harmless) exception in the console.
554
+ WidgetsBinding.instance.addPostFrameCallback((_) => _syncOrientation());
555
+ return;
556
+ }
546
557
  final target = widget.landscapeNotifier.value
547
558
  ? Orientation.landscape
548
559
  : Orientation.portrait;
549
- if (store.data.orientation != target) {
550
- store.data = store.data.copyWith(orientation: target);
560
+ if (data.orientation != target) {
561
+ store.data = data.copyWith(orientation: target);
562
+ }
563
+ }
564
+
565
+ /// [DevicePreviewStore.data] throws while the store is still finishing its async
566
+ /// initialization. Returns null instead of throwing so callers can skip or retry
567
+ /// cleanly (see _syncOrientation).
568
+ DevicePreviewData? _readData(DevicePreviewStore store) {
569
+ try {
570
+ return store.data;
571
+ } catch (_) {
572
+ return null;
551
573
  }
552
574
  }
553
575
 
@@ -1,4 +1,5 @@
1
1
  import 'package:flutter/material.dart';
2
+ import 'package:kasy_kit/components/kasy_app_bar.dart';
2
3
  import 'package:kasy_kit/components/kasy_card.dart';
3
4
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
4
5
  import 'package:kasy_kit/core/theme/theme.dart';
@@ -213,7 +214,12 @@ class _HomeFeedState extends State<HomeFeed> {
213
214
  child: Column(
214
215
  crossAxisAlignment: CrossAxisAlignment.stretch,
215
216
  children: <Widget>[
216
- const SizedBox(height: KasySpacing.belowChromeContentGap),
217
+ // App bar inset lives INSIDE the scroll so it scrolls away and the
218
+ // feed slides under the frosted bar (overlay pattern).
219
+ SizedBox(
220
+ height: kasyAppBarBodyTopOverlap(context) +
221
+ KasySpacing.belowChromeContentGap,
222
+ ),
217
223
  _FilterRow(selected: _selected, onSelect: _select),
218
224
  const SizedBox(height: KasySpacing.lg),
219
225
  Padding(
@@ -31,14 +31,11 @@ class HomePage extends ConsumerWidget {
31
31
  color: context.colors.background,
32
32
  child: Stack(
33
33
  children: [
34
- Positioned.fill(
35
- child: Padding(
36
- padding: EdgeInsets.only(
37
- top: kasyAppBarBodyTopOverlap(context),
38
- ),
39
- child: const HomeFeed(),
40
- ),
41
- ),
34
+ // True overlay: the feed fills the whole height and scrolls UNDER
35
+ // the frosted app bar (the bar's top inset lives inside the feed's
36
+ // scroll, so it scrolls away). This way, when the bar hides on
37
+ // scroll, content already fills the top — no empty app-bar band.
38
+ const Positioned.fill(child: HomeFeed()),
42
39
  Positioned(
43
40
  top: 0,
44
41
  left: 0,
@@ -46,6 +43,7 @@ class HomePage extends ConsumerWidget {
46
43
  child: KasyAppBar(
47
44
  title: t.home.dashboard.brand,
48
45
  style: KasyAppBarStyle.rootTab,
46
+ hideOnScroll: true,
49
47
  onThemeToggle: () {
50
48
  KasyHaptics.light(context);
51
49
  ThemeProvider.of(context).toggle();
@@ -105,6 +105,7 @@ class _NotificationsPageState extends ConsumerState<NotificationsPage> {
105
105
  return KasyOverlayScaffold(
106
106
  title: t.notifications.title,
107
107
  appBarStyle: KasyAppBarStyle.rootTab,
108
+ hideAppBarOnScroll: true,
108
109
  scrollController: _scrollController,
109
110
  trailing: Builder(
110
111
  builder: (ctx) => Row(
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
3
3
  import 'package:flutter_riverpod/flutter_riverpod.dart';
4
4
  import 'package:go_router/go_router.dart';
5
5
  import 'package:kasy_kit/components/components.dart';
6
+ import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
6
7
  import 'package:kasy_kit/core/config/features.dart';
7
8
  import 'package:kasy_kit/core/data/models/user.dart';
8
9
  import 'package:kasy_kit/core/haptics/haptic_feedback_notifier.dart';
@@ -41,6 +42,7 @@ class SettingsPage extends ConsumerWidget {
41
42
  return KasyOverlayScaffold(
42
43
  title: tr.title,
43
44
  appBarStyle: KasyAppBarStyle.rootTab,
45
+ hideAppBarOnScroll: true,
44
46
  trailing: Builder(
45
47
  builder: (ctx) => KasyChromeOrbIconButton(
46
48
  icon: KasyIcons.logout,
@@ -118,6 +120,10 @@ class SettingsPage extends ConsumerWidget {
118
120
  const SettingsDivider(),
119
121
  const HapticFeedbackSwitcher(),
120
122
  const SettingsDivider(),
123
+ if (kShowHideChromeOnScrollSetting) ...[
124
+ const HideChromeOnScrollSwitcher(),
125
+ const SettingsDivider(),
126
+ ],
121
127
  const LanguageSwitcher(),
122
128
  if (withLocalReminders) ...[
123
129
  const SettingsDivider(),
@@ -641,6 +647,10 @@ class _DesktopDetail extends ConsumerWidget {
641
647
  const SettingsDivider(),
642
648
  const HapticFeedbackSwitcher(),
643
649
  const SettingsDivider(),
650
+ if (kShowHideChromeOnScrollSetting) ...[
651
+ const HideChromeOnScrollSwitcher(),
652
+ const SettingsDivider(),
653
+ ],
644
654
  const LanguageSwitcher(),
645
655
  if (withLocalReminders) ...[
646
656
  const SettingsDivider(),
@@ -953,6 +963,22 @@ class HapticFeedbackSwitcher extends ConsumerWidget {
953
963
  }
954
964
  }
955
965
 
966
+ class HideChromeOnScrollSwitcher extends ConsumerWidget {
967
+ const HideChromeOnScrollSwitcher({super.key});
968
+
969
+ @override
970
+ Widget build(BuildContext context, WidgetRef ref) {
971
+ final isEnabled = ref.watch(hideChromeOnScrollProvider);
972
+ return SettingsSwitchTile(
973
+ icon: KasyIcons.eyeOff,
974
+ title: context.t.settings.hide_chrome_on_scroll_title,
975
+ value: isEnabled,
976
+ onChanged: (_) =>
977
+ ref.read(hideChromeOnScrollProvider.notifier).toggle(),
978
+ );
979
+ }
980
+ }
981
+
956
982
  class ThemeSwitcher extends StatelessWidget {
957
983
  const ThemeSwitcher({super.key});
958
984
 
@@ -4,6 +4,7 @@ import 'package:google_fonts/google_fonts.dart';
4
4
  import 'package:kasy_kit/components/components.dart';
5
5
  import 'package:kasy_kit/core/data/models/subscription.dart';
6
6
  import 'package:kasy_kit/core/theme/theme.dart';
7
+ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
7
8
  import 'package:kasy_kit/features/subscriptions/ui/component/premium_page_factory.dart';
8
9
  import 'package:kasy_kit/features/subscriptions/ui/widgets/paywall_empty_state.dart';
9
10
  import 'package:kasy_kit/features/subscriptions/ui/widgets/premium_background_gradient.dart';
@@ -15,6 +16,10 @@ import 'package:kasy_kit/i18n/translations.g.dart';
15
16
 
16
17
  /// Minimal paywall: benefits list + single CTA.
17
18
  /// Best for apps with one plan or when you want a clean, focused layout.
19
+ ///
20
+ /// Responsive: on phones (small) it fills the screen with the CTA pinned to the
21
+ /// bottom. On tablet/desktop (medium+) the content is centered in a max-width
22
+ /// column so it never stretches edge-to-edge or leaves a big empty gap.
18
23
  class PaywallMinimal extends StatelessWidget {
19
24
  final List<SubscriptionProduct> offers;
20
25
  final SubscriptionProduct? selectedOffer;
@@ -23,6 +28,10 @@ class PaywallMinimal extends StatelessWidget {
23
28
  final OnTap? onTapRestore;
24
29
  final OnTap? onSkip;
25
30
 
31
+ /// Max content width on tablet/desktop. Keeps the paywall readable and
32
+ /// centered instead of stretched across a wide viewport.
33
+ static const double _maxContentWidth = 460;
34
+
26
35
  const PaywallMinimal({
27
36
  super.key,
28
37
  required this.offers,
@@ -42,105 +51,175 @@ class PaywallMinimal extends StatelessWidget {
42
51
  return PaywallEmptyState(onTapRestore: onTapRestore, onSkip: onSkip);
43
52
  }
44
53
 
54
+ return Scaffold(
55
+ backgroundColor: context.colors.primary,
56
+ body: PremiumBackgroundGradient(
57
+ child: SafeArea(
58
+ child: LayoutBuilder(
59
+ builder: (context, constraints) {
60
+ final bool compact =
61
+ DeviceType.fromWidth(constraints.maxWidth) == DeviceType.small;
62
+ return compact ? _compactLayout(context) : _wideLayout(context);
63
+ },
64
+ ),
65
+ ),
66
+ ),
67
+ );
68
+ }
69
+
70
+ // --- Phone: full-height column, CTA pinned to the bottom. -----------------
71
+ Widget _compactLayout(BuildContext context) {
72
+ return Padding(
73
+ padding: const EdgeInsets.symmetric(
74
+ horizontal: KasySpacing.pageHorizontalGutter,
75
+ ),
76
+ child: Column(
77
+ crossAxisAlignment: CrossAxisAlignment.stretch,
78
+ children: [
79
+ Align(alignment: Alignment.topRight, child: _closeButton()),
80
+ const SizedBox(height: KasySpacing.lg),
81
+ _title(context),
82
+ const SizedBox(height: KasySpacing.lg),
83
+ ..._features(context),
84
+ const Spacer(),
85
+ _price(context),
86
+ const SizedBox(height: KasySpacing.smd),
87
+ _cta(context),
88
+ const SizedBox(height: KasySpacing.md),
89
+ _bottomMenu(context),
90
+ const SizedBox(height: KasySpacing.lg),
91
+ ],
92
+ ),
93
+ );
94
+ }
95
+
96
+ // --- Tablet/Desktop: centered max-width column, close pinned to corner. ---
97
+ Widget _wideLayout(BuildContext context) {
98
+ return Stack(
99
+ children: [
100
+ Center(
101
+ child: ConstrainedBox(
102
+ constraints: const BoxConstraints(maxWidth: _maxContentWidth),
103
+ child: SingleChildScrollView(
104
+ padding: const EdgeInsets.symmetric(
105
+ horizontal: KasySpacing.lg,
106
+ vertical: KasySpacing.xxl,
107
+ ),
108
+ child: Column(
109
+ crossAxisAlignment: CrossAxisAlignment.stretch,
110
+ mainAxisSize: MainAxisSize.min,
111
+ children: [
112
+ _title(context),
113
+ const SizedBox(height: KasySpacing.xl),
114
+ ..._features(context),
115
+ const SizedBox(height: KasySpacing.xl),
116
+ _price(context),
117
+ const SizedBox(height: KasySpacing.smd),
118
+ _cta(context),
119
+ const SizedBox(height: KasySpacing.md),
120
+ _bottomMenu(context),
121
+ ],
122
+ ),
123
+ ),
124
+ ),
125
+ ),
126
+ Positioned(
127
+ top: KasySpacing.sm,
128
+ right: KasySpacing.md,
129
+ child: _closeButton(),
130
+ ),
131
+ ],
132
+ );
133
+ }
134
+
135
+ // --- Shared content blocks (same widgets across breakpoints). -------------
136
+
137
+ Widget _closeButton() => AppCloseButton(onTap: () => onSkip?.call());
138
+
139
+ Widget _title(BuildContext context) {
140
+ return Animate(
141
+ effects: const [
142
+ FadeEffect(
143
+ delay: Duration(milliseconds: 100),
144
+ duration: Duration(milliseconds: 300),
145
+ ),
146
+ ],
147
+ child: Text(
148
+ Translations.of(context).premium.title_1,
149
+ textAlign: TextAlign.center,
150
+ style: GoogleFonts.albertSans(
151
+ fontSize: 28,
152
+ fontWeight: FontWeight.w700,
153
+ color: context.colors.onPrimary,
154
+ letterSpacing: -0.8,
155
+ ),
156
+ ),
157
+ );
158
+ }
159
+
160
+ List<Widget> _features(BuildContext context) {
45
161
  final translations = Translations.of(context).premium;
46
162
  final features = [
47
163
  translations.feature_1,
48
164
  translations.feature_2,
49
165
  translations.feature_3,
50
166
  ];
167
+ return [
168
+ ...AnimateList(
169
+ interval: 120.ms,
170
+ delay: 300.ms,
171
+ effects: [FadeEffect(duration: 300.ms)],
172
+ children: features.map((text) => PremiumFeature(text: text)).toList(),
173
+ ),
174
+ ];
175
+ }
51
176
 
52
- return Scaffold(
53
- backgroundColor: context.colors.primary,
54
- body: PremiumBackgroundGradient(
55
- child: SafeArea(
56
- child: Padding(
57
- padding: const EdgeInsets.symmetric(
58
- horizontal: KasySpacing.pageHorizontalGutter,
59
- ),
60
- child: Column(
61
- crossAxisAlignment: CrossAxisAlignment.stretch,
62
- children: [
63
- Align(
64
- alignment: Alignment.topRight,
65
- child: AppCloseButton(onTap: () => onSkip?.call()),
66
- ),
67
- const SizedBox(height: KasySpacing.lg),
68
- Animate(
69
- effects: const [
70
- FadeEffect(
71
- delay: Duration(milliseconds: 100),
72
- duration: Duration(milliseconds: 300),
73
- ),
74
- ],
75
- child: Text(
76
- translations.title_1,
77
- textAlign: TextAlign.center,
78
- style: GoogleFonts.albertSans(
79
- fontSize: 28,
80
- fontWeight: FontWeight.w700,
81
- color: context.colors.onPrimary,
82
- letterSpacing: -0.8,
83
- ),
84
- ),
85
- ),
86
- const SizedBox(height: KasySpacing.lg),
87
- ...AnimateList(
88
- interval: 120.ms,
89
- delay: 300.ms,
90
- effects: [FadeEffect(duration: 300.ms)],
91
- children: features
92
- .map((text) => PremiumFeature(text: text))
93
- .toList(),
94
- ),
95
- const Spacer(),
96
- Animate(
97
- effects: const [
98
- FadeEffect(
99
- delay: Duration(milliseconds: 400),
100
- duration: Duration(milliseconds: 300),
101
- ),
102
- ScaleEffect(
103
- delay: Duration(milliseconds: 400),
104
- duration: Duration(milliseconds: 500),
105
- curve: Curves.easeOut,
106
- ),
107
- ],
108
- child: Text(
109
- _effectiveOffer.formattedPrice(context),
110
- textAlign: TextAlign.center,
111
- style: context.textTheme.titleLarge?.copyWith(
112
- fontWeight: FontWeight.w600,
113
- color: context.colors.onPrimary,
114
- ),
115
- ),
116
- ),
117
- const SizedBox(height: KasySpacing.smd),
118
- Animate(
119
- effects: const [
120
- FadeEffect(
121
- delay: Duration(milliseconds: 500),
122
- duration: Duration(milliseconds: 300),
123
- ),
124
- ],
125
- child: KasyButton(
126
- label: translations.action_button,
127
- variant: KasyButtonVariant.inverse,
128
- isLoading: onTap == null,
129
- expand: true,
130
- onPressed: onTap,
131
- ),
132
- ),
133
- const SizedBox(height: KasySpacing.md),
134
- BottomPremiumMenu(
135
- textColor: context.colors.onPrimary,
136
- onTapRestore: onTapRestore,
137
- ),
138
- const SizedBox(height: KasySpacing.lg),
139
- ],
140
- ),
141
- ),
177
+ Widget _price(BuildContext context) {
178
+ return Animate(
179
+ effects: const [
180
+ FadeEffect(
181
+ delay: Duration(milliseconds: 400),
182
+ duration: Duration(milliseconds: 300),
183
+ ),
184
+ ScaleEffect(
185
+ delay: Duration(milliseconds: 400),
186
+ duration: Duration(milliseconds: 500),
187
+ curve: Curves.easeOut,
188
+ ),
189
+ ],
190
+ child: Text(
191
+ _effectiveOffer.formattedPrice(context),
192
+ textAlign: TextAlign.center,
193
+ style: context.textTheme.titleLarge?.copyWith(
194
+ fontWeight: FontWeight.w600,
195
+ color: context.colors.onPrimary,
196
+ ),
197
+ ),
198
+ );
199
+ }
200
+
201
+ Widget _cta(BuildContext context) {
202
+ return Animate(
203
+ effects: const [
204
+ FadeEffect(
205
+ delay: Duration(milliseconds: 500),
206
+ duration: Duration(milliseconds: 300),
142
207
  ),
208
+ ],
209
+ child: KasyButton(
210
+ label: Translations.of(context).premium.action_button,
211
+ variant: KasyButtonVariant.inverse,
212
+ isLoading: onTap == null,
213
+ expand: true,
214
+ onPressed: onTap,
143
215
  ),
144
216
  );
145
217
  }
218
+
219
+ Widget _bottomMenu(BuildContext context) {
220
+ return BottomPremiumMenu(
221
+ textColor: context.colors.onPrimary,
222
+ onTapRestore: onTapRestore,
223
+ );
224
+ }
146
225
  }