kasy-cli 1.31.10 → 1.31.12
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.
- package/lib/commands/add.js +31 -0
- package/lib/commands/new.js +0 -14
- package/lib/scaffold/CHANGELOG.json +9 -0
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +4 -2
- package/lib/scaffold/backends/supabase/edge-functions/ai-chat/index.ts +1 -1
- package/lib/scaffold/backends/supabase/edge-functions/delete-user-account/index.ts +1 -1
- package/lib/scaffold/backends/supabase/edge-functions/meta-track-event/index.ts +1 -1
- package/lib/scaffold/backends/supabase/edge-functions/revenuecat-webhook/index.ts +1 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +28 -10
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +4 -2
- package/lib/scaffold/backends/supabase/edge-functions/stripe-list-prices/index.ts +4 -3
- package/lib/scaffold/shared/generator-utils.js +11 -0
- package/package.json +2 -2
- package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +12 -0
- package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +10 -0
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +40 -5
- package/templates/firebase/functions/src/subscriptions/triggers.ts +35 -4
- package/templates/firebase/lib/components/kasy_app_bar.dart +22 -30
- package/templates/firebase/lib/components/kasy_toast.dart +6 -4
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +33 -14
- package/templates/firebase/lib/features/onboarding/repositories/user_infos_repository.dart +9 -2
- package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +2 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +18 -6
- package/templates/firebase/lib/features/subscriptions/api/subscription_payment_api.dart +8 -0
- package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +140 -69
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_bottom_menu.dart +11 -18
- package/templates/firebase/lib/i18n/en.i18n.json +4 -0
- package/templates/firebase/lib/i18n/es.i18n.json +4 -0
- package/templates/firebase/lib/i18n/pt.i18n.json +4 -0
- package/templates/firebase/web/stripe_success.html +138 -0
|
@@ -125,12 +125,9 @@ class KasyFrostedChromeBackground extends StatelessWidget {
|
|
|
125
125
|
this.padForStatusBar = true,
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
-
///
|
|
129
|
-
/// Derived from the global `surface` token so the bar lifts off the canvas
|
|
130
|
-
/// and follows light/dark automatically.
|
|
128
|
+
/// Solid bar fill from the global `surface` token; follows light/dark.
|
|
131
129
|
Color _tint(BuildContext context) {
|
|
132
|
-
|
|
133
|
-
return context.colors.surface.withValues(alpha: dark ? 0.88 : 0.82);
|
|
130
|
+
return context.colors.surface;
|
|
134
131
|
}
|
|
135
132
|
|
|
136
133
|
@override
|
|
@@ -194,40 +191,34 @@ class KasyFrostedChromeBackground extends StatelessWidget {
|
|
|
194
191
|
class KasyTopScrollFade extends StatelessWidget {
|
|
195
192
|
const KasyTopScrollFade({super.key});
|
|
196
193
|
|
|
197
|
-
///
|
|
198
|
-
|
|
199
|
-
/// where there is no status-bar inset.
|
|
200
|
-
static const double _solidBand = 20;
|
|
201
|
-
|
|
202
|
-
/// Smooth fade below the solid band before the wash is fully transparent.
|
|
203
|
-
static const double _fadeTail = 18;
|
|
194
|
+
/// Total wash height: status-bar strip + a fade zone below it.
|
|
195
|
+
static const double _contentFade = 40.0;
|
|
204
196
|
|
|
205
197
|
@override
|
|
206
198
|
Widget build(BuildContext context) {
|
|
207
199
|
final double topInset = MediaQuery.paddingOf(context).top;
|
|
208
|
-
|
|
209
|
-
final double
|
|
210
|
-
final
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
final double midStop = solidStop + (1 - solidStop) * 0.5;
|
|
200
|
+
// On web topInset = 0; enforce a 6 px minimum so the strip still exists.
|
|
201
|
+
final double solidHeight = topInset < 6 ? 6.0 : topInset;
|
|
202
|
+
final double totalHeight = solidHeight + _contentFade;
|
|
203
|
+
final Color base = context.colors.surface;
|
|
204
|
+
|
|
205
|
+
// Same gradient the grid cards use for their caption scrim — strong at the
|
|
206
|
+
// top, melting gradually to fully transparent at the bottom.
|
|
216
207
|
return IgnorePointer(
|
|
217
208
|
child: SizedBox(
|
|
218
|
-
height:
|
|
209
|
+
height: totalHeight,
|
|
219
210
|
child: DecoratedBox(
|
|
220
211
|
decoration: BoxDecoration(
|
|
221
212
|
gradient: LinearGradient(
|
|
222
213
|
begin: Alignment.topCenter,
|
|
223
214
|
end: Alignment.bottomCenter,
|
|
224
215
|
colors: <Color>[
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
216
|
+
base.withValues(alpha: 0.96),
|
|
217
|
+
base.withValues(alpha: 0.96),
|
|
218
|
+
base.withValues(alpha: 0.68),
|
|
219
|
+
base.withValues(alpha: 0.0),
|
|
229
220
|
],
|
|
230
|
-
stops: <double>[0.0,
|
|
221
|
+
stops: const <double>[0.0, 0.20, 0.55, 1.0],
|
|
231
222
|
),
|
|
232
223
|
),
|
|
233
224
|
),
|
|
@@ -399,12 +390,13 @@ class KasyAppBar extends StatelessWidget {
|
|
|
399
390
|
}
|
|
400
391
|
|
|
401
392
|
// A top wash sits BEHIND the bar (and stays put even when the bar hides) so
|
|
402
|
-
// content melts into the background at the top —
|
|
403
|
-
//
|
|
404
|
-
|
|
393
|
+
// content melts into the background at the top — mobile only (tablet/desktop
|
|
394
|
+
// use the sidebar/web header instead). Skipped for modal / page-sheet bars.
|
|
395
|
+
final bool isMobile =
|
|
396
|
+
MediaQuery.sizeOf(context).width < DeviceType.medium.breakpoint;
|
|
405
397
|
final bool standardBar =
|
|
406
398
|
useSafeArea && topInset == null && toolbarHeight == null;
|
|
407
|
-
if (!standardBar) return animatedChrome;
|
|
399
|
+
if (!standardBar || !isMobile) return animatedChrome;
|
|
408
400
|
return Stack(
|
|
409
401
|
clipBehavior: Clip.none,
|
|
410
402
|
children: <Widget>[
|
|
@@ -733,7 +733,9 @@ _Palette _palette(KasyColors c, KasyToastTone tone) {
|
|
|
733
733
|
accent: c.primary,
|
|
734
734
|
icon: KasyIcons.info,
|
|
735
735
|
buttonBg: c.primary,
|
|
736
|
-
|
|
736
|
+
// The Close button sits on a solid, vivid tone color — keep its label
|
|
737
|
+
// white for reliable contrast regardless of the brand "on" token.
|
|
738
|
+
buttonFg: Colors.white,
|
|
737
739
|
);
|
|
738
740
|
case KasyToastTone.success:
|
|
739
741
|
final Color successDark = HSLColor.fromColor(c.success)
|
|
@@ -745,21 +747,21 @@ _Palette _palette(KasyColors c, KasyToastTone tone) {
|
|
|
745
747
|
accent: successDark,
|
|
746
748
|
icon: KasyIcons.checkCircle,
|
|
747
749
|
buttonBg: successDark,
|
|
748
|
-
buttonFg:
|
|
750
|
+
buttonFg: Colors.white,
|
|
749
751
|
);
|
|
750
752
|
case KasyToastTone.warning:
|
|
751
753
|
return _Palette(
|
|
752
754
|
accent: c.warning,
|
|
753
755
|
icon: KasyIcons.privacy,
|
|
754
756
|
buttonBg: c.warning,
|
|
755
|
-
buttonFg:
|
|
757
|
+
buttonFg: Colors.white,
|
|
756
758
|
);
|
|
757
759
|
case KasyToastTone.danger:
|
|
758
760
|
return _Palette(
|
|
759
761
|
accent: c.error,
|
|
760
762
|
icon: KasyIcons.error,
|
|
761
763
|
buttonBg: c.error,
|
|
762
|
-
buttonFg:
|
|
764
|
+
buttonFg: Colors.white,
|
|
763
765
|
);
|
|
764
766
|
}
|
|
765
767
|
}
|
|
@@ -508,6 +508,11 @@ class _DeviceSwitchBridge extends StatefulWidget {
|
|
|
508
508
|
}
|
|
509
509
|
|
|
510
510
|
class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
|
|
511
|
+
// _syncOrientation retries on the next frame while the DevicePreview store is
|
|
512
|
+
// still mounting/initializing; cap the retries so it can never spin forever.
|
|
513
|
+
int _syncRetries = 0;
|
|
514
|
+
static const int _maxSyncRetries = 120;
|
|
515
|
+
|
|
511
516
|
@override
|
|
512
517
|
void initState() {
|
|
513
518
|
super.initState();
|
|
@@ -531,8 +536,8 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
|
|
|
531
536
|
}
|
|
532
537
|
|
|
533
538
|
void _onFrameVisibleChanged() {
|
|
534
|
-
|
|
535
|
-
|
|
539
|
+
final store = _store();
|
|
540
|
+
if (store == null) return;
|
|
536
541
|
final data = _readData(store);
|
|
537
542
|
if (data == null) return;
|
|
538
543
|
if (data.isFrameVisible != widget.frameVisibleNotifier.value) {
|
|
@@ -543,17 +548,21 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
|
|
|
543
548
|
void _onOrientationChanged() => _syncOrientation();
|
|
544
549
|
|
|
545
550
|
void _syncOrientation() {
|
|
546
|
-
|
|
547
|
-
final
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
|
|
551
|
+
final store = _store();
|
|
552
|
+
final data = store == null ? null : _readData(store);
|
|
553
|
+
if (store == null || data == null) {
|
|
554
|
+
// DevicePreview mounts its store asynchronously: on the first web frames the
|
|
555
|
+
// provider may not be in the tree yet (ProviderNotFound) or the store may be
|
|
556
|
+
// uninitialized (reading .data throws "Not initialized"). Retry on the next
|
|
557
|
+
// frame instead of surfacing a scary (and harmless) exception. Capped so it
|
|
558
|
+
// can never spin forever.
|
|
559
|
+
if (mounted && _syncRetries < _maxSyncRetries) {
|
|
560
|
+
_syncRetries++;
|
|
561
|
+
WidgetsBinding.instance.addPostFrameCallback((_) => _syncOrientation());
|
|
562
|
+
}
|
|
555
563
|
return;
|
|
556
564
|
}
|
|
565
|
+
_syncRetries = 0;
|
|
557
566
|
final target = widget.landscapeNotifier.value
|
|
558
567
|
? Orientation.landscape
|
|
559
568
|
: Orientation.portrait;
|
|
@@ -562,9 +571,19 @@ class _DeviceSwitchBridgeState extends State<_DeviceSwitchBridge> {
|
|
|
562
571
|
}
|
|
563
572
|
}
|
|
564
573
|
|
|
565
|
-
///
|
|
566
|
-
///
|
|
567
|
-
|
|
574
|
+
/// The DevicePreview store, or null if it isn't in the tree yet — `Provider.of`
|
|
575
|
+
/// throws ProviderNotFound on the first web frames before DevicePreview mounts it.
|
|
576
|
+
DevicePreviewStore? _store() {
|
|
577
|
+
if (!mounted) return null;
|
|
578
|
+
try {
|
|
579
|
+
return Provider.of<DevicePreviewStore>(context, listen: false);
|
|
580
|
+
} catch (_) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/// [DevicePreviewStore.data] throws "Not initialized" while the store finishes its
|
|
586
|
+
/// async init. Returns null instead so callers can skip or retry cleanly.
|
|
568
587
|
DevicePreviewData? _readData(DevicePreviewStore store) {
|
|
569
588
|
try {
|
|
570
589
|
return store.data;
|
|
@@ -17,11 +17,18 @@ class UserInfosRepository {
|
|
|
17
17
|
|
|
18
18
|
Future<void> save(String userId, UserInfoDetail info) async {
|
|
19
19
|
final entity = info.toEntity();
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
final alreadyExistingInfo =
|
|
22
22
|
await _userInfosApi.getByKey(userId, entity.key);
|
|
23
23
|
if (alreadyExistingInfo != null) {
|
|
24
|
-
|
|
24
|
+
// Reuse the existing document id so re-answering the same onboarding
|
|
25
|
+
// question overwrites its value instead of appending a duplicate. The
|
|
26
|
+
// fresh entity has a null id, which would otherwise make update() write
|
|
27
|
+
// to a new auto-id document every time (the duplication seen in testing).
|
|
28
|
+
return _userInfosApi.update(
|
|
29
|
+
userId,
|
|
30
|
+
entity.copyWith(id: alreadyExistingInfo.id),
|
|
31
|
+
);
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
return _userInfosApi.create(userId, entity);
|
|
@@ -34,6 +34,7 @@ class StripeBackendApi {
|
|
|
34
34
|
required String priceId,
|
|
35
35
|
String? successUrl,
|
|
36
36
|
String? cancelUrl,
|
|
37
|
+
String? locale,
|
|
37
38
|
}) async {
|
|
38
39
|
final res = await _functions
|
|
39
40
|
.httpsCallable('stripeFunctions-createCheckoutSession')
|
|
@@ -41,6 +42,7 @@ class StripeBackendApi {
|
|
|
41
42
|
'priceId': priceId,
|
|
42
43
|
if (successUrl != null) 'successUrl': successUrl,
|
|
43
44
|
if (cancelUrl != null) 'cancelUrl': cancelUrl,
|
|
45
|
+
if (locale != null) 'locale': locale,
|
|
44
46
|
});
|
|
45
47
|
return (res.data as Map)['url'] as String;
|
|
46
48
|
}
|
|
@@ -3,6 +3,7 @@ import 'package:kasy_kit/core/data/models/subscription.dart';
|
|
|
3
3
|
import 'package:kasy_kit/features/subscriptions/api/entities/subscription_entity.dart';
|
|
4
4
|
import 'package:kasy_kit/features/subscriptions/api/stripe_backend_api.dart';
|
|
5
5
|
import 'package:kasy_kit/features/subscriptions/api/subscription_payment_api.dart';
|
|
6
|
+
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
6
7
|
import 'package:url_launcher/url_launcher.dart';
|
|
7
8
|
|
|
8
9
|
/// Stripe web subscription provider.
|
|
@@ -33,13 +34,26 @@ class StripePaymentApi implements SubscriptionPaymentApi {
|
|
|
33
34
|
|
|
34
35
|
@override
|
|
35
36
|
Future<void> purchaseProduct(SubscriptionProduct product) async {
|
|
36
|
-
|
|
37
|
+
// On success, Stripe redirects this new tab to a tiny standalone page
|
|
38
|
+
// (web/stripe_success.html) instead of reloading the whole app in a second
|
|
39
|
+
// tab. The original app tab keeps polling and flips to premium via the
|
|
40
|
+
// webhook. On cancel we send the user back to the app where they were.
|
|
41
|
+
final appUrl = Uri.base.toString();
|
|
42
|
+
final successUrl = '${Uri.base.origin}/stripe_success.html';
|
|
37
43
|
final url = await _backend.createCheckoutSession(
|
|
38
44
|
priceId: product.skuId,
|
|
39
|
-
successUrl:
|
|
40
|
-
cancelUrl:
|
|
45
|
+
successUrl: successUrl,
|
|
46
|
+
cancelUrl: appUrl,
|
|
47
|
+
// Send the app language so the backend can persist it on the user and
|
|
48
|
+
// deliver subscription notifications in the right language (on web there
|
|
49
|
+
// is no registered device to read the locale from).
|
|
50
|
+
locale: LocaleSettings.instance.currentLocale.languageCode,
|
|
41
51
|
);
|
|
42
52
|
await _open(url);
|
|
53
|
+
// Checkout is now open in a new tab. Payment is NOT confirmed yet — the
|
|
54
|
+
// webhook will write the subscription record when the user pays. Throw so
|
|
55
|
+
// the provider knows to poll for activation instead of setting active now.
|
|
56
|
+
throw PendingWebCheckoutException();
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
@override
|
|
@@ -75,8 +89,6 @@ class StripePaymentApi implements SubscriptionPaymentApi {
|
|
|
75
89
|
Future<void> presentCodeRedemptionSheet() async {}
|
|
76
90
|
|
|
77
91
|
Future<void> _open(String url) async {
|
|
78
|
-
|
|
79
|
-
// Stripe Checkout / the Customer Portal.
|
|
80
|
-
await launchUrl(Uri.parse(url), webOnlyWindowName: '_self');
|
|
92
|
+
await launchUrl(Uri.parse(url), webOnlyWindowName: '_blank');
|
|
81
93
|
}
|
|
82
94
|
}
|
|
@@ -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';
|
|
@@ -31,6 +32,17 @@ class PremiumStateNotifier extends _$PremiumStateNotifier {
|
|
|
31
32
|
|
|
32
33
|
@override
|
|
33
34
|
Future<PremiumState> build() async {
|
|
35
|
+
// A user who already has an active subscription must ALWAYS land on the
|
|
36
|
+
// "subscribed" state, even if the offer list fails to load. On web the
|
|
37
|
+
// offers come from Stripe and the fetch can transiently fail; without this
|
|
38
|
+
// early return a paying user would wrongly see the empty-products paywall
|
|
39
|
+
// and "lose" the confirmation that they paid. The active view
|
|
40
|
+
// ([ActivePremiumContent]) does not need the offer list.
|
|
41
|
+
final currentSubscription = _userState.subscription;
|
|
42
|
+
if (currentSubscription is SubscriptionStateData) {
|
|
43
|
+
return PremiumState.active(activeOffer: currentSubscription.activeOffer);
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
try {
|
|
35
47
|
// If you have installed the remote config brick
|
|
36
48
|
// you can use it like this
|
|
@@ -41,25 +53,13 @@ class PremiumStateNotifier extends _$PremiumStateNotifier {
|
|
|
41
53
|
final offers = await _subscriptionRepository.getOffers();
|
|
42
54
|
if (offers.isEmpty) {
|
|
43
55
|
_logger.w(
|
|
44
|
-
'
|
|
56
|
+
'The store returned no subscription offers. The paywall will show '
|
|
45
57
|
'an empty-products state instead of a blank screen.',
|
|
46
58
|
);
|
|
47
59
|
return const PremiumState(offers: []);
|
|
48
60
|
}
|
|
49
61
|
|
|
50
|
-
return
|
|
51
|
-
SubscriptionStateData(:final activeOffer) => PremiumState.active(
|
|
52
|
-
activeOffer: offers.firstWhere(
|
|
53
|
-
(element) => element.skuId == activeOffer?.skuId,
|
|
54
|
-
orElse: () => offers.first,
|
|
55
|
-
),
|
|
56
|
-
),
|
|
57
|
-
SubscriptionInactiveStateData() => PremiumState(
|
|
58
|
-
offers: offers,
|
|
59
|
-
selectedOffer: offers.first,
|
|
60
|
-
),
|
|
61
|
-
_ => PremiumState(offers: offers, selectedOffer: offers.first),
|
|
62
|
-
};
|
|
62
|
+
return PremiumState(offers: offers, selectedOffer: offers.first);
|
|
63
63
|
} catch (err, stack) {
|
|
64
64
|
// RevenueCat CONFIGURATION_ERROR (code 23) means the products exist in the
|
|
65
65
|
// dashboard but aren't live on the store yet (e.g. "Ready to Submit").
|
|
@@ -123,61 +123,102 @@ class PremiumStateNotifier extends _$PremiumStateNotifier {
|
|
|
123
123
|
String? paywall,
|
|
124
124
|
String? redirectRoute,
|
|
125
125
|
}) async {
|
|
126
|
-
|
|
127
|
-
PremiumStateData(:final offers) =>
|
|
128
|
-
PremiumState.sending(
|
|
129
|
-
isPremium: false,
|
|
130
|
-
offers: offers,
|
|
131
|
-
selectedOffer: offer,
|
|
132
|
-
),
|
|
133
|
-
),
|
|
126
|
+
final currentOffers = switch (state.value) {
|
|
127
|
+
PremiumStateData(:final offers) => offers,
|
|
134
128
|
_ => throw "cannot purchase while active",
|
|
135
129
|
};
|
|
130
|
+
state = AsyncData(
|
|
131
|
+
PremiumState.sending(isPremium: false, offers: currentOffers, selectedOffer: offer),
|
|
132
|
+
);
|
|
136
133
|
try {
|
|
137
134
|
final entitlements = await _subscriptionRepository.purchase(offer);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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);
|
|
135
|
+
await _activateAfterPurchase(offer, entitlements, paywall, redirectRoute);
|
|
136
|
+
} on PendingWebCheckoutException {
|
|
137
|
+
// Stripe web: checkout opened in a new tab. Poll until the webhook
|
|
138
|
+
// activates the subscription or we time out.
|
|
139
|
+
await _waitForWebPayment(offer, currentOffers, paywall: paywall, redirectRoute: redirectRoute);
|
|
159
140
|
} catch (err, stackTrace) {
|
|
160
|
-
state =
|
|
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
|
-
};
|
|
141
|
+
state = AsyncData(PremiumState(offers: currentOffers, selectedOffer: offer));
|
|
170
142
|
if (err is UserCancelledPurchaseException) return;
|
|
171
143
|
await Sentry.captureException(err, stackTrace: stackTrace);
|
|
172
144
|
_logger.e("...PremiumStateNotifier: purchase failed $err : $stackTrace");
|
|
173
145
|
final t = ref.read(translationsProvider);
|
|
174
|
-
ref
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
146
|
+
ref.read(toastProvider).error(
|
|
147
|
+
title: t.premium.error_title,
|
|
148
|
+
text: t.premium.error_text,
|
|
149
|
+
reason: "We were unable to process your subscription",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Future<void> _activateAfterPurchase(
|
|
155
|
+
SubscriptionProduct offer,
|
|
156
|
+
List<Entitlement>? entitlements,
|
|
157
|
+
String? paywall,
|
|
158
|
+
String? redirectRoute,
|
|
159
|
+
) async {
|
|
160
|
+
state = AsyncData(PremiumState.active(activeOffer: offer));
|
|
161
|
+
await _analyticsApi?.logEvent("purchase", {
|
|
162
|
+
"skuId": offer.skuId,
|
|
163
|
+
"price": offer.price,
|
|
164
|
+
"duration": offer.duration,
|
|
165
|
+
"paywall": paywall,
|
|
166
|
+
});
|
|
167
|
+
await ref
|
|
168
|
+
.read(userStateNotifierProvider.notifier)
|
|
169
|
+
.refreshSubscription(product: offer, entitlements: entitlements);
|
|
170
|
+
final t = ref.read(translationsProvider);
|
|
171
|
+
ref.read(toastProvider).success(
|
|
172
|
+
title: t.premium.purchase_success_title,
|
|
173
|
+
text: t.premium.purchase_success_text,
|
|
174
|
+
);
|
|
175
|
+
await Future.delayed(const Duration(seconds: 2));
|
|
176
|
+
if (redirectRoute != null) ref.read(goRouterProvider).go(redirectRoute);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// Polls the subscription backend every 5 s until the Stripe webhook delivers
|
|
180
|
+
/// the activation (up to 10 min). Runs while the state is [PremiumStateSending].
|
|
181
|
+
Future<void> _waitForWebPayment(
|
|
182
|
+
SubscriptionProduct offer,
|
|
183
|
+
List<SubscriptionProduct> currentOffers, {
|
|
184
|
+
String? paywall,
|
|
185
|
+
String? redirectRoute,
|
|
186
|
+
}) async {
|
|
187
|
+
const maxAttempts = 120; // 10 min at 5 s intervals
|
|
188
|
+
const interval = Duration(seconds: 5);
|
|
189
|
+
final userId = _userState.user.idOrNull;
|
|
190
|
+
if (userId == null) {
|
|
191
|
+
state = AsyncData(PremiumState(offers: currentOffers, selectedOffer: offer));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
for (var i = 0; i < maxAttempts; i++) {
|
|
197
|
+
await Future.delayed(interval);
|
|
198
|
+
if (state.value is! PremiumStateSending) return;
|
|
199
|
+
try {
|
|
200
|
+
final sub = await _subscriptionRepository.get(userId);
|
|
201
|
+
if (sub.isActive) {
|
|
202
|
+
await _activateAfterPurchase(offer, null, paywall, redirectRoute);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
} catch (_) {
|
|
206
|
+
// network / backend error → keep polling; disposal propagates to
|
|
207
|
+
// the outer catch on the next state.value access.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Timed out — revert to paywall and ask the user to restore if they paid.
|
|
212
|
+
if (state.value is PremiumStateSending) {
|
|
213
|
+
state = AsyncData(PremiumState(offers: currentOffers, selectedOffer: offer));
|
|
214
|
+
final t = ref.read(translationsProvider);
|
|
215
|
+
ref.read(toastProvider).error(
|
|
216
|
+
title: t.premium.web_checkout_timeout_title,
|
|
217
|
+
text: t.premium.web_checkout_timeout_text,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} catch (_) {
|
|
221
|
+
// Provider was disposed while polling (user left the paywall). Ignore.
|
|
181
222
|
}
|
|
182
223
|
}
|
|
183
224
|
|
|
@@ -229,15 +270,45 @@ class PremiumStateNotifier extends _$PremiumStateNotifier {
|
|
|
229
270
|
|
|
230
271
|
try {
|
|
231
272
|
await _subscriptionRepository.restorePurchase();
|
|
273
|
+
|
|
274
|
+
// restorePurchase is a no-op on web (Stripe status lives server-side), so
|
|
275
|
+
// we re-read the backend to learn the real state: the webhook may have
|
|
276
|
+
// already written the subscription. Only flip to active when the backend
|
|
277
|
+
// actually confirms it — otherwise we'd show a false "restored" success.
|
|
278
|
+
final userId = _userState.user.idOrNull;
|
|
279
|
+
final restored = userId == null
|
|
280
|
+
? null
|
|
281
|
+
: await _subscriptionRepository.get(userId);
|
|
232
282
|
final t = ref.read(translationsProvider);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
283
|
+
|
|
284
|
+
if (restored is SubscriptionStateData && restored.isActive) {
|
|
285
|
+
await ref
|
|
286
|
+
.read(userStateNotifierProvider.notifier)
|
|
287
|
+
.refreshSubscription(
|
|
288
|
+
product: restored.activeOffer,
|
|
289
|
+
entitlements: restored.entitlements,
|
|
290
|
+
);
|
|
291
|
+
state = AsyncData(
|
|
292
|
+
PremiumState.active(activeOffer: restored.activeOffer),
|
|
293
|
+
);
|
|
294
|
+
ref.read(toastProvider).success(
|
|
295
|
+
title: t.premium.restore_success_title,
|
|
296
|
+
text: t.premium.restore_success_text,
|
|
297
|
+
);
|
|
298
|
+
await Future.delayed(const Duration(seconds: 2));
|
|
299
|
+
if (redirectRoute != null) ref.read(goRouterProvider).go(redirectRoute);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Nothing to restore: revert to the paywall and tell the user, instead of
|
|
304
|
+
// a misleading success toast.
|
|
305
|
+
state = AsyncData(
|
|
306
|
+
PremiumStateData(offers: data.offers, selectedOffer: data.selectedOffer),
|
|
307
|
+
);
|
|
308
|
+
ref.read(toastProvider).alert(
|
|
309
|
+
title: t.premium.restore_none_title,
|
|
310
|
+
text: t.premium.restore_none_text,
|
|
311
|
+
);
|
|
241
312
|
} catch (err, trace) {
|
|
242
313
|
_logger.e("Error while restoring purchase: $err : $trace");
|
|
243
314
|
state = AsyncData(
|
|
@@ -76,26 +76,19 @@ class BottomPremiumMenu extends StatelessWidget {
|
|
|
76
76
|
Widget _buildRestoreButton(BuildContext context) {
|
|
77
77
|
final translations = Translations.of(context).premium;
|
|
78
78
|
final color = textColor ?? context.colors.muted;
|
|
79
|
+
// While a purchase / restore is in flight, onTapRestore is null. We keep the
|
|
80
|
+
// label visible but disabled instead of swapping it for a spinner, so the
|
|
81
|
+
// only loading indicator on the paywall is the main CTA button.
|
|
79
82
|
return Padding(
|
|
80
83
|
padding: const EdgeInsets.symmetric(horizontal: KasySpacing.sm),
|
|
81
|
-
child:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
90
|
-
: KasyButton.iconOnly(
|
|
91
|
-
icon: KasyIcons.refresh,
|
|
92
|
-
variant: KasyButtonVariant.ghost,
|
|
93
|
-
foregroundColor: color,
|
|
94
|
-
isLoading: true,
|
|
95
|
-
onPressed: null,
|
|
96
|
-
semanticLabel: translations.restore_action,
|
|
97
|
-
size: KasyButtonSize.small,
|
|
98
|
-
),
|
|
84
|
+
child: KasyButton(
|
|
85
|
+
variant: KasyButtonVariant.ghost,
|
|
86
|
+
label: translations.restore_action,
|
|
87
|
+
size: KasyButtonSize.small,
|
|
88
|
+
foregroundColor: color,
|
|
89
|
+
fontWeight: FontWeight.w500,
|
|
90
|
+
onPressed: onTapRestore,
|
|
91
|
+
),
|
|
99
92
|
);
|
|
100
93
|
}
|
|
101
94
|
}
|
|
@@ -206,6 +206,10 @@
|
|
|
206
206
|
"purchase_success_text": "Thank you for your trust",
|
|
207
207
|
"error_title": "Error",
|
|
208
208
|
"error_text": "An error occurred. Please try again",
|
|
209
|
+
"web_checkout_timeout_title": "Payment not confirmed",
|
|
210
|
+
"web_checkout_timeout_text": "We did not receive payment confirmation. If you already paid, tap Restore.",
|
|
211
|
+
"restore_none_title": "No subscription found",
|
|
212
|
+
"restore_none_text": "We could not find an active subscription to restore.",
|
|
209
213
|
"comparison": {
|
|
210
214
|
"title": "Premium plan comparison",
|
|
211
215
|
"features_label": "Features",
|
|
@@ -206,6 +206,10 @@
|
|
|
206
206
|
"purchase_success_text": "Gracias por tu confianza",
|
|
207
207
|
"error_title": "Error",
|
|
208
208
|
"error_text": "Ocurrió un error. Inténtalo de nuevo",
|
|
209
|
+
"web_checkout_timeout_title": "Pago no confirmado",
|
|
210
|
+
"web_checkout_timeout_text": "No recibimos confirmación del pago. Si ya pagaste, toca Restaurar.",
|
|
211
|
+
"restore_none_title": "No se encontró ninguna suscripción",
|
|
212
|
+
"restore_none_text": "No encontramos una suscripción activa para restaurar.",
|
|
209
213
|
"comparison": {
|
|
210
214
|
"title": "Comparación de planes Premium",
|
|
211
215
|
"features_label": "Características",
|
|
@@ -206,6 +206,10 @@
|
|
|
206
206
|
"purchase_success_text": "Obrigado pela sua confiança",
|
|
207
207
|
"error_title": "Erro",
|
|
208
208
|
"error_text": "Ocorreu um erro. Tente novamente",
|
|
209
|
+
"web_checkout_timeout_title": "Pagamento não confirmado",
|
|
210
|
+
"web_checkout_timeout_text": "Não recebemos a confirmação do pagamento. Se você já pagou, toque em Restaurar.",
|
|
211
|
+
"restore_none_title": "Nenhuma assinatura encontrada",
|
|
212
|
+
"restore_none_text": "Não encontramos uma assinatura ativa para restaurar.",
|
|
209
213
|
"comparison": {
|
|
210
214
|
"title": "Comparação de planos Premium",
|
|
211
215
|
"features_label": "Recursos",
|