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.
- package/lib/commands/add.js +31 -0
- package/lib/commands/new.js +7 -24
- package/lib/scaffold/CHANGELOG.json +9 -0
- package/lib/scaffold/backends/supabase/config.toml +39 -2
- package/lib/scaffold/backends/supabase/deploy.js +14 -16
- package/lib/scaffold/backends/supabase/edge-functions/admin-list-users/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +27 -9
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-list-prices/index.ts +3 -2
- package/lib/scaffold/catalog.js +24 -0
- package/lib/scaffold/shared/generator-utils.js +11 -0
- package/package.json +2 -2
- package/templates/firebase/assets/images/premium-bg.jpg +0 -0
- package/templates/firebase/assets/images/premium-switch-header.png +0 -0
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +21 -4
- package/templates/firebase/lib/components/kasy_app_bar.dart +109 -1
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +26 -7
- package/templates/firebase/lib/core/bottom_menu/kasy_bottom_bar_factory.dart +22 -33
- package/templates/firebase/lib/core/chrome/chrome_visibility.dart +119 -0
- package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +12 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +25 -3
- package/templates/firebase/lib/features/home/home_feed.dart +7 -1
- package/templates/firebase/lib/features/home/home_page.dart +6 -8
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +1 -0
- package/templates/firebase/lib/features/settings/settings_page.dart +26 -0
- package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +5 -3
- package/templates/firebase/lib/features/subscriptions/api/subscription_payment_api.dart +8 -0
- package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +89 -47
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +169 -90
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +219 -202
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +67 -30
- package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +16 -6
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_bottom_menu.dart +11 -18
- package/templates/firebase/lib/i18n/en.i18n.json +3 -0
- package/templates/firebase/lib/i18n/es.i18n.json +3 -0
- 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:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
//
|
|
163
|
-
// (
|
|
164
|
-
// pill
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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 (
|
|
550
|
-
store.data =
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
PremiumStateData(:final offers) =>
|
|
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
|
-
|
|
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);
|
|
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 =
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|