kasy-cli 1.31.9 → 1.31.11

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 (36) hide show
  1. package/lib/commands/add.js +31 -0
  2. package/lib/commands/new.js +7 -24
  3. package/lib/scaffold/CHANGELOG.json +9 -0
  4. package/lib/scaffold/backends/supabase/config.toml +39 -2
  5. package/lib/scaffold/backends/supabase/deploy.js +14 -16
  6. package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
  7. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +27 -9
  8. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +3 -1
  9. package/lib/scaffold/backends/supabase/edge-functions/stripe-list-prices/index.ts +3 -2
  10. package/lib/scaffold/catalog.js +24 -0
  11. package/lib/scaffold/shared/generator-utils.js +11 -0
  12. package/package.json +2 -2
  13. package/templates/firebase/assets/images/premium-bg.jpg +0 -0
  14. package/templates/firebase/assets/images/premium-switch-header.png +0 -0
  15. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +21 -4
  16. package/templates/firebase/lib/components/kasy_app_bar.dart +109 -1
  17. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +26 -7
  18. package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +22 -33
  19. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +119 -0
  20. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +12 -0
  21. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +25 -3
  22. package/templates/firebase/lib/features/home/home_feed.dart +7 -1
  23. package/templates/firebase/lib/features/home/home_page.dart +6 -8
  24. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -0
  25. package/templates/firebase/lib/features/settings/settings_page.dart +26 -0
  26. package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +5 -3
  27. package/templates/firebase/lib/features/subscriptions/api/subscription_payment_api.dart +8 -0
  28. package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +89 -47
  29. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +169 -90
  30. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +219 -202
  31. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +67 -30
  32. package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +16 -6
  33. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_bottom_menu.dart +11 -18
  34. package/templates/firebase/lib/i18n/en.i18n.json +3 -0
  35. package/templates/firebase/lib/i18n/es.i18n.json +3 -0
  36. package/templates/firebase/lib/i18n/pt.i18n.json +3 -0
@@ -8,6 +8,7 @@ import 'package:kasy_kit/core/bottom_menu/active_tab_notifier.dart';
8
8
  import 'package:kasy_kit/core/bottom_menu/bottom_router.dart';
9
9
  import 'package:kasy_kit/core/bottom_menu/kasy_bottom_bar_factory.dart';
10
10
  import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
11
+ import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
11
12
  import 'package:kasy_kit/core/data/models/user.dart';
12
13
  import 'package:kasy_kit/core/states/logout_action.dart';
13
14
  import 'package:kasy_kit/core/states/user_state_notifier.dart';
@@ -20,6 +21,9 @@ import 'package:kasy_kit/i18n/translations.g.dart';
20
21
  /// [bart.BartScaffold.onRouteChanged]. See [activeTabRouteNotifier].
21
22
  void _rememberActiveTab(bart.BartMenuRoute route) {
22
23
  activeTabRouteNotifier.value = route.path;
24
+ // A fresh tab always starts with the chrome shown (it may have been hidden by
25
+ // scrolling on the previous tab).
26
+ KasyChromeVisibility.instance.resetShown();
23
27
  }
24
28
 
25
29
  /// Bottom navigation host powered by Bart (https://pub.dev/packages/bart).
@@ -106,13 +110,28 @@ class BottomMenu extends StatelessWidget {
106
110
  Brightness.light => SystemUiOverlayStyle.dark,
107
111
  },
108
112
  child: ResponsiveLayout(
109
- small: bart.BartScaffold(
110
- routesBuilder: subRoutes,
111
- bottomBar: kasyPaddedSurfaceBottomBar(),
112
- initialRoute: resolvedInitialRoute,
113
- showBottomBarOnStart: showBottomBarOnStart,
114
- scaffoldOptions: scaffoldOptions,
115
- onRouteChanged: _rememberActiveTab,
113
+ small: Consumer(
114
+ builder: (context, ref, _) {
115
+ // Watching the provider here keeps the persisted on/off setting in
116
+ // sync with the controller, and scopes scroll tracking to the
117
+ // mobile shell only (detail screens push on the root navigator, so
118
+ // their scrolls never reach this listener).
119
+ ref.watch(hideChromeOnScrollProvider);
120
+ return NotificationListener<ScrollUpdateNotification>(
121
+ onNotification: (notification) {
122
+ KasyChromeVisibility.instance.handleScrollUpdate(notification);
123
+ return false; // let the notification keep bubbling
124
+ },
125
+ child: bart.BartScaffold(
126
+ routesBuilder: subRoutes,
127
+ bottomBar: kasyPaddedSurfaceBottomBar(),
128
+ initialRoute: resolvedInitialRoute,
129
+ showBottomBarOnStart: showBottomBarOnStart,
130
+ scaffoldOptions: scaffoldOptions,
131
+ onRouteChanged: _rememberActiveTab,
132
+ ),
133
+ );
134
+ },
116
135
  ),
117
136
  medium: connectedScaffold(),
118
137
  large: connectedScaffold(),
@@ -3,6 +3,7 @@ import 'package:bart/bart/bart_model.dart';
3
3
  import 'package:bart/bart/router_delegate.dart';
4
4
  import 'package:bart/bart/widgets/bottom_bar/styles/bottom_bar_custom.dart';
5
5
  import 'package:flutter/material.dart';
6
+ import 'package:kasy_kit/core/chrome/chrome_visibility.dart';
6
7
  import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
7
8
  import 'package:kasy_kit/core/theme/theme.dart';
8
9
 
@@ -159,42 +160,30 @@ final class KasyPaddedSurfaceBottomBarFactory extends BartBottomBarFactory {
159
160
  ),
160
161
  );
161
162
 
162
- // Low bottom-up fade in the page background color: opaque at the very bottom
163
- // (hides content scrolling under the bar), fully transparent again around the
164
- // pill's mid-height so it stays a discreet wash low on the bar rather than a
165
- // tall veil. Follows light/dark via the background token; same RGB at both
166
- // ends so there is no grey halo.
167
- final Widget fade = IgnorePointer(
168
- child: DecoratedBox(
169
- decoration: BoxDecoration(
170
- gradient: LinearGradient(
171
- begin: Alignment.bottomCenter,
172
- end: Alignment.topCenter,
173
- colors: [
174
- colors.background,
175
- colors.background,
176
- colors.background.withValues(alpha: 0),
177
- ],
178
- stops: const [0.0, 0.33, 0.66],
179
- ),
163
+ // Slide the pill down as the chrome collapses, in lock-step with the top
164
+ // app bar (same notifier). Travel covers the whole floating height so the
165
+ // pill fully clears the screen edge when hidden. No bottom wash: the pill
166
+ // just slides off (and never goes translucent).
167
+ final double travel = _kFloatingTopGap + _kBarHeight + bottomGap;
168
+ return ValueListenableBuilder<double>(
169
+ valueListenable: KasyChromeVisibility.instance.reveal,
170
+ builder: (BuildContext context, double reveal, Widget? child) {
171
+ final double hidden = 1 - reveal;
172
+ return Transform.translate(
173
+ offset: Offset(0, travel * hidden),
174
+ child: child,
175
+ );
176
+ },
177
+ child: Padding(
178
+ padding: EdgeInsets.only(
179
+ left: _kFloatingSideMargin,
180
+ right: _kFloatingSideMargin,
181
+ top: _kFloatingTopGap,
182
+ bottom: bottomGap,
180
183
  ),
184
+ child: pill,
181
185
  ),
182
186
  );
183
-
184
- return Stack(
185
- children: [
186
- Positioned.fill(child: fade),
187
- Padding(
188
- padding: EdgeInsets.only(
189
- left: _kFloatingSideMargin,
190
- right: _kFloatingSideMargin,
191
- top: _kFloatingTopGap,
192
- bottom: bottomGap,
193
- ),
194
- child: pill,
195
- ),
196
- ],
197
- );
198
187
  }
199
188
  }
200
189
 
@@ -0,0 +1,119 @@
1
+ import 'package:flutter/widgets.dart';
2
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+ import 'package:kasy_kit/core/shared_preferences/shared_preferences.dart';
4
+
5
+ /// Single source of truth for how much of the app's "chrome" — the top
6
+ /// [KasyAppBar] and the bottom navigation bar — is revealed while the user
7
+ /// scrolls. `1` = fully shown, `0` = fully hidden; both bars interpolate on
8
+ /// this value so they slide away and come back together.
9
+ ///
10
+ /// Behaviour (mobile only — tablet/desktop use the sidebar, which never hides):
11
+ /// - Scrolling DOWN collapses the chrome glued to the gesture: a fast flick
12
+ /// hides it fast, a slow drag hides it gradually.
13
+ /// - Scrolling UP slowly keeps it hidden; only a fast fling up brings it back.
14
+ /// - Reaching the top always brings it back.
15
+ ///
16
+ /// Exposed as a global singleton (same pattern as `activeTabRouteNotifier` /
17
+ /// `kasyContentFocusTarget`) because both the app bar component and the bottom
18
+ /// bar factory need the same value without threading it through every page.
19
+ class KasyChromeVisibility {
20
+ KasyChromeVisibility._();
21
+
22
+ static final KasyChromeVisibility instance = KasyChromeVisibility._();
23
+
24
+ /// `1` = chrome fully visible, `0` = fully hidden.
25
+ final ValueNotifier<double> reveal = ValueNotifier<double>(1);
26
+
27
+ bool _enabled = true;
28
+
29
+ /// Whether the "hide on scroll" feature is on. Off → the chrome stays pinned.
30
+ bool get enabled => _enabled;
31
+ set enabled(bool value) {
32
+ _enabled = value;
33
+ if (!value) reveal.value = 1; // pin the chrome back when turned off
34
+ }
35
+
36
+ /// Distance (logical px) over which the chrome fully collapses while scrolling
37
+ /// down. Small, so it feels glued to the gesture.
38
+ static const double _collapseDistance = 90;
39
+
40
+ /// An upward scroll delta at least this large in a single update counts as a
41
+ /// fast fling up and snaps the chrome back into view, even mid-page.
42
+ static const double _flingUpThreshold = 14;
43
+
44
+ /// Within this many px of the top, the chrome is always fully shown.
45
+ static const double _topThreshold = 6;
46
+
47
+ /// Bring the chrome fully back. Call on tab changes so a new screen never
48
+ /// starts with hidden chrome.
49
+ void resetShown() => reveal.value = 1;
50
+
51
+ /// Feed every vertical scroll update here (from a [NotificationListener] in
52
+ /// the shell). Updates [reveal] in place.
53
+ void handleScrollUpdate(ScrollUpdateNotification notification) {
54
+ if (!_enabled) return;
55
+ if (notification.metrics.axis != Axis.vertical) return;
56
+ final double delta = notification.scrollDelta ?? 0;
57
+ final double pixels = notification.metrics.pixels;
58
+
59
+ if (pixels <= _topThreshold) {
60
+ reveal.value = 1; // at the top → always shown
61
+ return;
62
+ }
63
+ if (delta > 0) {
64
+ // Scrolling down: collapse proportionally — fast scroll (big delta) hides
65
+ // fast, slow scroll hides gradually. Glued to the gesture.
66
+ reveal.value = (reveal.value - delta / _collapseDistance).clamp(0.0, 1.0);
67
+ } else if (delta < 0 && -delta >= _flingUpThreshold) {
68
+ // Scrolling up: a slow drag keeps it hidden; only a fast fling reveals.
69
+ reveal.value = 1;
70
+ }
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Configuration — edit these to change the experience your app ships with.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /// Whether the Settings screen shows the "hide bars when scrolling" toggle to
79
+ /// the END USER.
80
+ ///
81
+ /// - `true` → the toggle appears in Settings → Preferences and the user
82
+ /// decides; its initial value is [kHideChromeOnScrollDefault].
83
+ /// - `false` → the toggle is hidden and the behaviour is LOCKED to
84
+ /// [kHideChromeOnScrollDefault] (the user cannot change it).
85
+ const bool kShowHideChromeOnScrollSetting = true;
86
+
87
+ /// The default behaviour: `true` = the app bar and bottom menu hide while
88
+ /// scrolling down (and come back on scroll up / at the top); `false` = they
89
+ /// stay fixed. Used as the toggle's initial value when shown, and as the locked
90
+ /// value when [kShowHideChromeOnScrollSetting] is `false`.
91
+ const bool kHideChromeOnScrollDefault = true;
92
+
93
+ /// Persisted on/off for the hide-on-scroll behaviour. Wires the effective value
94
+ /// into [KasyChromeVisibility] so the bars react immediately.
95
+ final hideChromeOnScrollProvider =
96
+ NotifierProvider<HideChromeOnScrollNotifier, bool>(
97
+ HideChromeOnScrollNotifier.new,
98
+ );
99
+
100
+ class HideChromeOnScrollNotifier extends Notifier<bool> {
101
+ @override
102
+ bool build() {
103
+ // When the user toggle is hidden, the behaviour is locked to the default
104
+ // and any previously saved preference is ignored.
105
+ final bool enabled = kShowHideChromeOnScrollSetting
106
+ ? (ref.read(sharedPreferencesProvider).getHideChromeOnScroll() ??
107
+ kHideChromeOnScrollDefault)
108
+ : kHideChromeOnScrollDefault;
109
+ KasyChromeVisibility.instance.enabled = enabled;
110
+ return enabled;
111
+ }
112
+
113
+ Future<void> toggle() async {
114
+ final bool next = !state;
115
+ state = next;
116
+ KasyChromeVisibility.instance.enabled = next;
117
+ await ref.read(sharedPreferencesProvider).setHideChromeOnScroll(next);
118
+ }
119
+ }
@@ -52,6 +52,18 @@ class SharedPreferencesBuilder implements OnStartService {
52
52
  return prefs.getBool('haptic_feedback_enabled') ?? true;
53
53
  }
54
54
 
55
+ /// Whether the top app bar and bottom menu hide while scrolling down and come
56
+ /// back on scroll up / at the top. Defaults to on.
57
+ Future<void> setHideChromeOnScroll(bool enabled) async {
58
+ await prefs.setBool('hide_chrome_on_scroll', enabled);
59
+ }
60
+
61
+ /// Null when the user has never set it — the caller applies the configured
62
+ /// default ([kHideChromeOnScrollDefault]).
63
+ bool? getHideChromeOnScroll() {
64
+ return prefs.getBool('hide_chrome_on_scroll');
65
+ }
66
+
55
67
  Future<void> setBiometricEnabled(bool enabled) async {
56
68
  await prefs.setBool('biometric_enabled', enabled);
57
69
  }
@@ -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
 
@@ -40,6 +40,10 @@ class StripePaymentApi implements SubscriptionPaymentApi {
40
40
  cancelUrl: returnUrl,
41
41
  );
42
42
  await _open(url);
43
+ // Checkout is now open in a new tab. Payment is NOT confirmed yet — the
44
+ // webhook will write the subscription record when the user pays. Throw so
45
+ // the provider knows to poll for activation instead of setting active now.
46
+ throw PendingWebCheckoutException();
43
47
  }
44
48
 
45
49
  @override
@@ -75,8 +79,6 @@ class StripePaymentApi implements SubscriptionPaymentApi {
75
79
  Future<void> presentCodeRedemptionSheet() async {}
76
80
 
77
81
  Future<void> _open(String url) async {
78
- // On web, redirect the current tab so the user returns to the app after
79
- // Stripe Checkout / the Customer Portal.
80
- await launchUrl(Uri.parse(url), webOnlyWindowName: '_self');
82
+ await launchUrl(Uri.parse(url), webOnlyWindowName: '_blank');
81
83
  }
82
84
  }
@@ -51,3 +51,11 @@ abstract interface class SubscriptionPaymentApi {
51
51
  class UserCancelledPurchaseException implements Exception {
52
52
  UserCancelledPurchaseException();
53
53
  }
54
+
55
+ /// Thrown by [StripePaymentApi.purchaseProduct] on web after the hosted
56
+ /// Checkout URL is opened in a new browser tab. The purchase is NOT complete —
57
+ /// the caller must poll for subscription activation (via webhook) instead of
58
+ /// treating the returned Future as payment confirmation.
59
+ class PendingWebCheckoutException implements Exception {
60
+ PendingWebCheckoutException();
61
+ }
@@ -1,6 +1,7 @@
1
1
  import 'package:flutter/foundation.dart';
2
2
  import 'package:flutter/services.dart' show PlatformException;
3
3
  import 'package:kasy_kit/core/data/api/analytics_api.dart';
4
+ import 'package:kasy_kit/core/data/models/entitlement.dart';
4
5
  import 'package:kasy_kit/core/data/models/subscription.dart';
5
6
  import 'package:kasy_kit/core/states/models/user_state.dart';
6
7
  import 'package:kasy_kit/core/states/translations.dart';
@@ -8,7 +9,7 @@ import 'package:kasy_kit/core/states/user_state_notifier.dart';
8
9
  import 'package:kasy_kit/core/toast/toast_service.dart';
9
10
  import 'package:kasy_kit/features/feedbacks/repositories/feature_request_repository.dart';
10
11
  import 'package:kasy_kit/features/subscriptions/api/subscription_payment_api.dart'
11
- show UserCancelledPurchaseException;
12
+ show PendingWebCheckoutException, UserCancelledPurchaseException;
12
13
  import 'package:kasy_kit/features/subscriptions/providers/models/premium_state.dart';
13
14
  import 'package:kasy_kit/features/subscriptions/repositories/subscription_repository.dart';
14
15
  import 'package:kasy_kit/router.dart';
@@ -123,61 +124,102 @@ class PremiumStateNotifier extends _$PremiumStateNotifier {
123
124
  String? paywall,
124
125
  String? redirectRoute,
125
126
  }) async {
126
- state = switch (state.value) {
127
- PremiumStateData(:final offers) => AsyncData(
128
- PremiumState.sending(
129
- isPremium: false,
130
- offers: offers,
131
- selectedOffer: offer,
132
- ),
133
- ),
127
+ final currentOffers = switch (state.value) {
128
+ PremiumStateData(:final offers) => offers,
134
129
  _ => throw "cannot purchase while active",
135
130
  };
131
+ state = AsyncData(
132
+ PremiumState.sending(isPremium: false, offers: currentOffers, selectedOffer: offer),
133
+ );
136
134
  try {
137
135
  final entitlements = await _subscriptionRepository.purchase(offer);
138
- state = AsyncData(PremiumState.active(activeOffer: offer));
139
- await _analyticsApi?.logEvent("purchase", {
140
- "skuId": offer.skuId,
141
- "price": offer.price,
142
- // "promotion": offer.promotion,
143
- "duration": offer.duration,
144
- "paywall": paywall,
145
- });
146
- // let's refresh the user state
147
- await ref
148
- .read(userStateNotifierProvider.notifier)
149
- .refreshSubscription(product: offer, entitlements: entitlements);
150
- final t = ref.read(translationsProvider);
151
- ref
152
- .read(toastProvider)
153
- .success(
154
- title: t.premium.purchase_success_title,
155
- text: t.premium.purchase_success_text,
156
- );
157
- await Future.delayed(const Duration(seconds: 2));
158
- if (redirectRoute != null) ref.read(goRouterProvider).go(redirectRoute);
136
+ await _activateAfterPurchase(offer, entitlements, paywall, redirectRoute);
137
+ } on PendingWebCheckoutException {
138
+ // Stripe web: checkout opened in a new tab. Poll until the webhook
139
+ // activates the subscription or we time out.
140
+ await _waitForWebPayment(offer, currentOffers, paywall: paywall, redirectRoute: redirectRoute);
159
141
  } catch (err, stackTrace) {
160
- state = switch (state.value) {
161
- PremiumStateData(:final offers) => AsyncData(
162
- PremiumState(offers: offers, selectedOffer: offer),
163
- ),
164
- PremiumStateSending(:final offers) => AsyncData(
165
- PremiumState(offers: offers, selectedOffer: offer),
166
- ),
167
- PremiumStateActive() => state,
168
- _ => throw "cannot purchase while active",
169
- };
142
+ state = AsyncData(PremiumState(offers: currentOffers, selectedOffer: offer));
170
143
  if (err is UserCancelledPurchaseException) return;
171
144
  await Sentry.captureException(err, stackTrace: stackTrace);
172
145
  _logger.e("...PremiumStateNotifier: purchase failed $err : $stackTrace");
173
146
  final t = ref.read(translationsProvider);
174
- ref
175
- .read(toastProvider)
176
- .error(
177
- title: t.premium.error_title,
178
- text: t.premium.error_text,
179
- reason: "We were unable to process your subscription",
180
- );
147
+ ref.read(toastProvider).error(
148
+ title: t.premium.error_title,
149
+ text: t.premium.error_text,
150
+ reason: "We were unable to process your subscription",
151
+ );
152
+ }
153
+ }
154
+
155
+ Future<void> _activateAfterPurchase(
156
+ SubscriptionProduct offer,
157
+ List<Entitlement>? entitlements,
158
+ String? paywall,
159
+ String? redirectRoute,
160
+ ) async {
161
+ state = AsyncData(PremiumState.active(activeOffer: offer));
162
+ await _analyticsApi?.logEvent("purchase", {
163
+ "skuId": offer.skuId,
164
+ "price": offer.price,
165
+ "duration": offer.duration,
166
+ "paywall": paywall,
167
+ });
168
+ await ref
169
+ .read(userStateNotifierProvider.notifier)
170
+ .refreshSubscription(product: offer, entitlements: entitlements);
171
+ final t = ref.read(translationsProvider);
172
+ ref.read(toastProvider).success(
173
+ title: t.premium.purchase_success_title,
174
+ text: t.premium.purchase_success_text,
175
+ );
176
+ await Future.delayed(const Duration(seconds: 2));
177
+ if (redirectRoute != null) ref.read(goRouterProvider).go(redirectRoute);
178
+ }
179
+
180
+ /// Polls the subscription backend every 5 s until the Stripe webhook delivers
181
+ /// the activation (up to 10 min). Runs while the state is [PremiumStateSending].
182
+ Future<void> _waitForWebPayment(
183
+ SubscriptionProduct offer,
184
+ List<SubscriptionProduct> currentOffers, {
185
+ String? paywall,
186
+ String? redirectRoute,
187
+ }) async {
188
+ const maxAttempts = 120; // 10 min at 5 s intervals
189
+ const interval = Duration(seconds: 5);
190
+ final userId = _userState.user.idOrNull;
191
+ if (userId == null) {
192
+ state = AsyncData(PremiumState(offers: currentOffers, selectedOffer: offer));
193
+ return;
194
+ }
195
+
196
+ try {
197
+ for (var i = 0; i < maxAttempts; i++) {
198
+ await Future.delayed(interval);
199
+ if (state.value is! PremiumStateSending) return;
200
+ try {
201
+ final sub = await _subscriptionRepository.get(userId);
202
+ if (sub.isActive) {
203
+ await _activateAfterPurchase(offer, null, paywall, redirectRoute);
204
+ return;
205
+ }
206
+ } catch (_) {
207
+ // network / backend error → keep polling; disposal propagates to
208
+ // the outer catch on the next state.value access.
209
+ }
210
+ }
211
+
212
+ // Timed out — revert to paywall and ask the user to restore if they paid.
213
+ if (state.value is PremiumStateSending) {
214
+ state = AsyncData(PremiumState(offers: currentOffers, selectedOffer: offer));
215
+ final t = ref.read(translationsProvider);
216
+ ref.read(toastProvider).error(
217
+ title: t.premium.web_checkout_timeout_title,
218
+ text: t.premium.web_checkout_timeout_text,
219
+ );
220
+ }
221
+ } catch (_) {
222
+ // Provider was disposed while polling (user left the paywall). Ignore.
181
223
  }
182
224
  }
183
225