Matthias Nott
2026-04-01 98e5695f9c77c594a103e9e81128798d41bae46a
feat: StoreKit 2 IAP — free tier with 2 sessions and 15min message TTL
3 files added
9 files modified
changed files
ios/Podfile.lock patch | view | blame | history
ios/Runner/Configuration.storekit patch | view | blame | history
lib/main.dart patch | view | blame | history
lib/providers/providers.dart patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/screens/settings_screen.dart patch | view | blame | history
lib/services/purchase_service.dart patch | view | blame | history
lib/widgets/paywall_banner.dart patch | view | blame | history
lib/widgets/session_drawer.dart patch | view | blame | history
macos/Flutter/GeneratedPluginRegistrant.swift patch | view | blame | history
pubspec.lock patch | view | blame | history
pubspec.yaml patch | view | blame | history
ios/Podfile.lock
....@@ -50,6 +50,9 @@
5050 - Flutter
5151 - image_picker_ios (0.0.1):
5252 - Flutter
53
+ - in_app_purchase_storekit (0.0.1):
54
+ - Flutter
55
+ - FlutterMacOS
5356 - permission_handler_apple (9.3.0):
5457 - Flutter
5558 - push (0.0.1):
....@@ -79,6 +82,7 @@
7982 - flutter_app_badger (from `.symlinks/plugins/flutter_app_badger/ios`)
8083 - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
8184 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
85
+ - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
8286 - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
8387 - push (from `.symlinks/plugins/push/darwin`)
8488 - record_ios (from `.symlinks/plugins/record_ios/ios`)
....@@ -112,6 +116,8 @@
112116 :path: ".symlinks/plugins/flutter_secure_storage/ios"
113117 image_picker_ios:
114118 :path: ".symlinks/plugins/image_picker_ios/ios"
119
+ in_app_purchase_storekit:
120
+ :path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
115121 permission_handler_apple:
116122 :path: ".symlinks/plugins/permission_handler_apple/ios"
117123 push:
....@@ -137,6 +143,7 @@
137143 flutter_app_badger: 16b371e989d04cd265df85be2c3851b49cb68d18
138144 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
139145 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
146
+ in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8
140147 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
141148 push: 91373ae39c5341c6de6adefa3fda7f7287d646bf
142149 record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
ios/Runner/Configuration.storekit
....@@ -0,0 +1,30 @@
1
+{
2
+ "identifier" : "2F3A87BA-1234-4CDE-ABCD-9876543210AB",
3
+ "nonConsumableProducts" : [
4
+ {
5
+ "displayPrice" : "4.99",
6
+ "familySharable" : false,
7
+ "internalID" : "F3A9827C-5678-4BCD-EFAB-0123456789CD",
8
+ "localizations" : [
9
+ {
10
+ "description" : "Unlock unlimited sessions and persistent message history in PAILot.",
11
+ "displayName" : "PAILot Pro",
12
+ "locale" : "en_US"
13
+ }
14
+ ],
15
+ "productID" : "com.tekmidian.pailot.fullaccess",
16
+ "referenceName" : "PAILot Pro Full Access",
17
+ "type" : "NonConsumable"
18
+ }
19
+ ],
20
+ "settings" : {
21
+ "_enableAskToBuyInterruptionForTesting" : false,
22
+ "_enableBackgroundDeliveryForTesting" : true,
23
+ "_enableFamilySharingForTesting" : false,
24
+ "_storeKitErrors" : []
25
+ },
26
+ "version" : {
27
+ "major" : 2,
28
+ "minor" : 0
29
+ }
30
+}
lib/main.dart
....@@ -7,6 +7,7 @@
77 import 'theme/app_theme.dart';
88 import 'providers/providers.dart';
99 import 'services/audio_service.dart';
10
+import 'services/purchase_service.dart';
1011
1112 void main() async {
1213 WidgetsFlutterBinding.ensureInitialized();
....@@ -23,10 +24,15 @@
2324 // Initialize audio service
2425 AudioService.init();
2526
27
+ // Initialize purchase service (loads cached status + starts StoreKit listener)
28
+ await PurchaseService.instance.initialize();
29
+
2630 runApp(
2731 ProviderScope(
2832 overrides: [
2933 themeModeProvider.overrideWith((ref) => themeMode),
34
+ // Seed isProProvider from StoreKit cache so first frame is correct.
35
+ isProProvider.overrideWith((ref) => PurchaseService.instance.isPro),
3036 ],
3137 child: const PAILotApp(),
3238 ),
....@@ -47,6 +53,18 @@
4753 void initState() {
4854 super.initState();
4955 _router = createRouter();
56
+ // Keep isProProvider in sync whenever PurchaseService notifies a change.
57
+ PurchaseService.instance.addListener(_onPurchaseChanged);
58
+ }
59
+
60
+ @override
61
+ void dispose() {
62
+ PurchaseService.instance.removeListener(_onPurchaseChanged);
63
+ super.dispose();
64
+ }
65
+
66
+ void _onPurchaseChanged() {
67
+ ref.read(isProProvider.notifier).state = PurchaseService.instance.isPro;
5068 }
5169
5270 @override
lib/providers/providers.dart
....@@ -211,3 +211,9 @@
211211 // ChatScreen sets this when MQTT is initialized; NavigateScreen reads it.
212212 // Using a Riverpod provider eliminates the stale static reference risk.
213213 final navigateNotifierProvider = StateProvider<NavigateNotifier?>((ref) => null);
214
+
215
+// --- Pro / Purchase Status ---
216
+
217
+/// Whether the user has purchased PAILot Pro (full access).
218
+/// Updated by PurchaseService after StoreKit verification.
219
+final isProProvider = StateProvider<bool>((ref) => false);
lib/screens/chat_screen.dart
....@@ -23,9 +23,11 @@
2323 import '../services/navigate_notifier.dart';
2424 import '../services/push_service.dart';
2525 import '../theme/app_theme.dart';
26
+import '../services/purchase_service.dart';
2627 import '../widgets/command_bar.dart';
2728 import '../widgets/input_bar.dart';
2829 import '../widgets/message_bubble.dart';
30
+import '../widgets/paywall_banner.dart';
2931 import '../widgets/session_drawer.dart';
3032 import '../widgets/status_dot.dart';
3133 import '../widgets/toast_overlay.dart';
....@@ -1332,6 +1334,11 @@
13321334 _sendCommand('create');
13331335 }
13341336
1337
+ /// Called when the user taps an upgrade CTA in the drawer or paywall banner.
1338
+ Future<void> _handleUpgrade() async {
1339
+ await PurchaseService.instance.purchaseFullAccess();
1340
+ }
1341
+
13351342 void _handleSessionRename(Session session, String newName) {
13361343 _sendCommand('rename', {'sessionId': session.id, 'name': newName});
13371344 final sessions = ref.read(sessionsProvider);
....@@ -1385,7 +1392,17 @@
13851392
13861393 @override
13871394 Widget build(BuildContext context) {
1388
- final messages = ref.watch(messagesProvider);
1395
+ final allMessages = ref.watch(messagesProvider);
1396
+ final isPro = ref.watch(isProProvider);
1397
+ // Free tier: filter out messages older than 15 minutes on display.
1398
+ // Storage is unchanged — messages reappear if the user later upgrades.
1399
+ final messages = isPro
1400
+ ? allMessages
1401
+ : allMessages.where((m) {
1402
+ final ts = DateTime.fromMillisecondsSinceEpoch(m.timestamp);
1403
+ final age = DateTime.now().difference(ts);
1404
+ return age <= kFreeTierMessageTtl;
1405
+ }).toList();
13891406 final wsStatus = ref.watch(wsStatusProvider);
13901407 final isTyping = ref.watch(isTypingProvider);
13911408 final connectionDetail = ref.watch(connectionDetailProvider);
....@@ -1452,15 +1469,18 @@
14521469 sessions: sessions,
14531470 activeSessionId: activeSession?.id,
14541471 unreadCounts: unreadCounts,
1472
+ isPro: ref.watch(isProProvider),
14551473 onSelect: (s) => _switchSession(s.id),
14561474 onRemove: _handleSessionRemove,
14571475 onRename: _handleSessionRename,
14581476 onReorder: _handleSessionReorder,
14591477 onNewSession: _handleNewSession,
14601478 onRefresh: _refreshSessions,
1479
+ onUpgrade: _handleUpgrade,
14611480 ),
14621481 body: Column(
14631482 children: [
1483
+ const PaywallBanner(),
14641484 Expanded(
14651485 child: ListView.builder(
14661486 controller: _scrollController,
lib/screens/settings_screen.dart
....@@ -5,6 +5,7 @@
55 import '../models/server_config.dart';
66 import '../providers/providers.dart';
77 import '../services/mqtt_service.dart' show ConnectionStatus;
8
+import '../services/purchase_service.dart';
89 import '../services/wol_service.dart';
910 import '../theme/app_theme.dart';
1011 import '../widgets/status_dot.dart';
....@@ -25,10 +26,12 @@
2526 late final TextEditingController _macController;
2627 late final TextEditingController _mqttTokenController;
2728 bool _isWaking = false;
29
+ bool _iapLoading = false;
2830
2931 @override
3032 void initState() {
3133 super.initState();
34
+ PurchaseService.instance.addListener(_onPurchaseChanged);
3235 final config = ref.read(serverConfigProvider);
3336 _localHostController =
3437 TextEditingController(text: config?.localHost ?? '');
....@@ -46,6 +49,7 @@
4649
4750 @override
4851 void dispose() {
52
+ PurchaseService.instance.removeListener(_onPurchaseChanged);
4953 _localHostController.dispose();
5054 _vpnHostController.dispose();
5155 _remoteHostController.dispose();
....@@ -53,6 +57,45 @@
5357 _macController.dispose();
5458 _mqttTokenController.dispose();
5559 super.dispose();
60
+ }
61
+
62
+ void _onPurchaseChanged() {
63
+ if (!mounted) return;
64
+ final isPro = PurchaseService.instance.isPro;
65
+ ref.read(isProProvider.notifier).state = isPro;
66
+ setState(() => _iapLoading = PurchaseService.instance.isLoading);
67
+ final error = PurchaseService.instance.errorMessage;
68
+ if (error != null && mounted) {
69
+ ScaffoldMessenger.of(context).showSnackBar(
70
+ SnackBar(content: Text(error)),
71
+ );
72
+ }
73
+ if (isPro && mounted) {
74
+ ScaffoldMessenger.of(context).showSnackBar(
75
+ const SnackBar(
76
+ content: Text('PAILot Pro activated. Enjoy unlimited sessions!'),
77
+ duration: Duration(seconds: 3),
78
+ ),
79
+ );
80
+ }
81
+ }
82
+
83
+ Future<void> _handleUpgrade() async {
84
+ setState(() => _iapLoading = true);
85
+ await PurchaseService.instance.purchaseFullAccess();
86
+ }
87
+
88
+ Future<void> _handleRestore() async {
89
+ setState(() => _iapLoading = true);
90
+ await PurchaseService.instance.restorePurchases();
91
+ if (mounted) {
92
+ ScaffoldMessenger.of(context).showSnackBar(
93
+ const SnackBar(
94
+ content: Text('Checking for previous purchases...'),
95
+ duration: Duration(seconds: 2),
96
+ ),
97
+ );
98
+ }
5699 }
57100
58101 Future<void> _save() async {
....@@ -328,6 +371,89 @@
328371 icon: const Icon(Icons.shield_outlined),
329372 label: const Text('Reset Server Trust'),
330373 ),
374
+ const SizedBox(height: 24),
375
+
376
+ // --- PAILot Pro ---
377
+ const Divider(),
378
+ const SizedBox(height: 8),
379
+ Row(
380
+ children: [
381
+ const Icon(Icons.star, size: 18),
382
+ const SizedBox(width: 8),
383
+ Text(
384
+ 'PAILot Pro',
385
+ style: Theme.of(context).textTheme.titleMedium,
386
+ ),
387
+ const Spacer(),
388
+ Consumer(
389
+ builder: (ctx, ref, _) {
390
+ final isPro = ref.watch(isProProvider);
391
+ return Chip(
392
+ label: Text(
393
+ isPro ? 'Active' : 'Free Tier',
394
+ style: TextStyle(
395
+ fontSize: 11,
396
+ color: isPro ? Colors.white : null,
397
+ ),
398
+ ),
399
+ backgroundColor: isPro ? AppColors.accent : null,
400
+ padding: EdgeInsets.zero,
401
+ visualDensity: VisualDensity.compact,
402
+ );
403
+ },
404
+ ),
405
+ ],
406
+ ),
407
+ const SizedBox(height: 4),
408
+ Text(
409
+ 'Free: 2 sessions, messages expire after 15 min\n'
410
+ 'Pro: unlimited sessions, messages persist forever',
411
+ style: Theme.of(context)
412
+ .textTheme
413
+ .bodySmall
414
+ ?.copyWith(color: Colors.grey.shade500),
415
+ ),
416
+ const SizedBox(height: 12),
417
+ Consumer(
418
+ builder: (ctx, ref, _) {
419
+ final isPro = ref.watch(isProProvider);
420
+ if (isPro) {
421
+ return const Center(
422
+ child: Text(
423
+ 'Full access active',
424
+ style: TextStyle(color: AppColors.accent),
425
+ ),
426
+ );
427
+ }
428
+ return Column(
429
+ crossAxisAlignment: CrossAxisAlignment.stretch,
430
+ children: [
431
+ ElevatedButton.icon(
432
+ onPressed: _iapLoading ? null : _handleUpgrade,
433
+ icon: _iapLoading
434
+ ? const SizedBox(
435
+ width: 16,
436
+ height: 16,
437
+ child:
438
+ CircularProgressIndicator(strokeWidth: 2),
439
+ )
440
+ : const Icon(Icons.upgrade),
441
+ label: const Text('Upgrade to Pro — \$4.99'),
442
+ style: ElevatedButton.styleFrom(
443
+ backgroundColor: AppColors.accent,
444
+ foregroundColor: Colors.white,
445
+ ),
446
+ ),
447
+ const SizedBox(height: 8),
448
+ OutlinedButton.icon(
449
+ onPressed: _iapLoading ? null : _handleRestore,
450
+ icon: const Icon(Icons.restore),
451
+ label: const Text('Restore Purchase'),
452
+ ),
453
+ ],
454
+ );
455
+ },
456
+ ),
331457 const SizedBox(height: 12),
332458 ],
333459 ),
lib/services/purchase_service.dart
....@@ -0,0 +1,197 @@
1
+import 'dart:async';
2
+
3
+import 'package:flutter/foundation.dart';
4
+import 'package:in_app_purchase/in_app_purchase.dart';
5
+import 'package:shared_preferences/shared_preferences.dart';
6
+
7
+/// Product ID for the one-time full-access purchase.
8
+const String kFullAccessProductId = 'com.tekmidian.pailot.fullaccess';
9
+
10
+/// Maximum sessions allowed on the free tier.
11
+const int kFreeTierMaxSessions = 2;
12
+
13
+/// Maximum message age for free-tier users (15 minutes).
14
+const Duration kFreeTierMessageTtl = Duration(minutes: 15);
15
+
16
+/// Shared preference key for caching purchase status locally.
17
+const String _kProCacheKey = 'pailot_is_pro';
18
+
19
+/// Service that manages the StoreKit 2 / in_app_purchase lifecycle.
20
+///
21
+/// Usage:
22
+/// final svc = PurchaseService();
23
+/// await svc.initialize();
24
+/// svc.addListener(() { ... });
25
+/// bool pro = svc.isPro;
26
+///
27
+/// Call [dispose] when done.
28
+class PurchaseService extends ChangeNotifier {
29
+ PurchaseService._();
30
+
31
+ static final PurchaseService instance = PurchaseService._();
32
+
33
+ bool _isPro = false;
34
+ bool _isLoading = false;
35
+ String? _errorMessage;
36
+
37
+ StreamSubscription<List<PurchaseDetails>>? _subscription;
38
+
39
+ /// Whether the user has purchased full access.
40
+ bool get isPro => _isPro;
41
+
42
+ /// True while a purchase or restore operation is in progress.
43
+ bool get isLoading => _isLoading;
44
+
45
+ /// Non-null if the last operation produced an error message.
46
+ String? get errorMessage => _errorMessage;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Lifecycle
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /// Initialize the service. Call once at app startup.
53
+ Future<void> initialize() async {
54
+ // Restore cached value immediately so UI doesn't flicker.
55
+ final prefs = await SharedPreferences.getInstance();
56
+ _isPro = prefs.getBool(_kProCacheKey) ?? false;
57
+ notifyListeners();
58
+
59
+ // Listen for ongoing purchase updates.
60
+ final purchaseUpdated = InAppPurchase.instance.purchaseStream;
61
+ _subscription = purchaseUpdated.listen(
62
+ _handlePurchaseUpdates,
63
+ onError: (Object err) {
64
+ _errorMessage = err.toString();
65
+ notifyListeners();
66
+ },
67
+ );
68
+
69
+ // Verify with StoreKit on each launch (catches refunds / family sharing).
70
+ await restorePurchases(silent: true);
71
+ }
72
+
73
+ @override
74
+ void dispose() {
75
+ _subscription?.cancel();
76
+ super.dispose();
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Public API
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /// Initiate the purchase flow for full access.
84
+ Future<void> purchaseFullAccess() async {
85
+ _errorMessage = null;
86
+ _isLoading = true;
87
+ notifyListeners();
88
+
89
+ try {
90
+ final bool available = await InAppPurchase.instance.isAvailable();
91
+ if (!available) {
92
+ _errorMessage = 'Store not available. Check your internet connection.';
93
+ _isLoading = false;
94
+ notifyListeners();
95
+ return;
96
+ }
97
+
98
+ final ProductDetailsResponse response = await InAppPurchase.instance
99
+ .queryProductDetails({kFullAccessProductId});
100
+
101
+ if (response.error != null || response.productDetails.isEmpty) {
102
+ _errorMessage =
103
+ 'Product not found. Please try again later.';
104
+ _isLoading = false;
105
+ notifyListeners();
106
+ return;
107
+ }
108
+
109
+ final ProductDetails product = response.productDetails.first;
110
+ final PurchaseParam param = PurchaseParam(productDetails: product);
111
+ await InAppPurchase.instance.buyNonConsumable(purchaseParam: param);
112
+ // Result arrives via purchaseStream — _isLoading cleared there.
113
+ } catch (e) {
114
+ _errorMessage = 'Purchase failed: $e';
115
+ _isLoading = false;
116
+ notifyListeners();
117
+ }
118
+ }
119
+
120
+ /// Restore previously completed purchases (also called on app launch).
121
+ Future<void> restorePurchases({bool silent = false}) async {
122
+ if (!silent) {
123
+ _errorMessage = null;
124
+ _isLoading = true;
125
+ notifyListeners();
126
+ }
127
+
128
+ try {
129
+ await InAppPurchase.instance.restorePurchases();
130
+ // Results arrive asynchronously via purchaseStream.
131
+ // For non-silent restores _isLoading is cleared there.
132
+ if (silent) {
133
+ // Give the stream a moment to deliver any results.
134
+ await Future<void>.delayed(const Duration(seconds: 2));
135
+ }
136
+ } catch (e) {
137
+ if (!silent) {
138
+ _errorMessage = 'Restore failed: $e';
139
+ _isLoading = false;
140
+ notifyListeners();
141
+ }
142
+ }
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Internal
147
+ // ---------------------------------------------------------------------------
148
+
149
+ Future<void> _handlePurchaseUpdates(
150
+ List<PurchaseDetails> purchases) async {
151
+ for (final PurchaseDetails purchase in purchases) {
152
+ if (purchase.productID != kFullAccessProductId) continue;
153
+
154
+ switch (purchase.status) {
155
+ case PurchaseStatus.purchased:
156
+ case PurchaseStatus.restored:
157
+ await _deliverPurchase(purchase);
158
+ break;
159
+
160
+ case PurchaseStatus.error:
161
+ _errorMessage = purchase.error?.message ?? 'Purchase failed.';
162
+ _isLoading = false;
163
+ notifyListeners();
164
+ break;
165
+
166
+ case PurchaseStatus.canceled:
167
+ _isLoading = false;
168
+ notifyListeners();
169
+ break;
170
+
171
+ case PurchaseStatus.pending:
172
+ // Show loading while pending (e.g. Ask to Buy).
173
+ _isLoading = true;
174
+ notifyListeners();
175
+ break;
176
+ }
177
+
178
+ // Complete the transaction to prevent it from being re-delivered.
179
+ if (purchase.pendingCompletePurchase) {
180
+ await InAppPurchase.instance.completePurchase(purchase);
181
+ }
182
+ }
183
+ }
184
+
185
+ Future<void> _deliverPurchase(PurchaseDetails purchase) async {
186
+ _isPro = true;
187
+ _isLoading = false;
188
+ _errorMessage = null;
189
+
190
+ // Persist so the next app launch restores from cache quickly.
191
+ final prefs = await SharedPreferences.getInstance();
192
+ await prefs.setBool(_kProCacheKey, true);
193
+
194
+ debugPrint('[Purchase] Full access granted for ${purchase.productID}');
195
+ notifyListeners();
196
+ }
197
+}
lib/widgets/paywall_banner.dart
....@@ -0,0 +1,116 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+
4
+import '../providers/providers.dart';
5
+import '../services/purchase_service.dart';
6
+import '../theme/app_theme.dart';
7
+
8
+/// Dismissible banner shown at the top of the chat screen when a free-tier
9
+/// limit has been reached. Tapping "Upgrade" initiates the IAP flow.
10
+class PaywallBanner extends ConsumerStatefulWidget {
11
+ const PaywallBanner({super.key});
12
+
13
+ @override
14
+ ConsumerState<PaywallBanner> createState() => _PaywallBannerState();
15
+}
16
+
17
+class _PaywallBannerState extends ConsumerState<PaywallBanner> {
18
+ bool _dismissed = false;
19
+
20
+ @override
21
+ Widget build(BuildContext context) {
22
+ final isPro = ref.watch(isProProvider);
23
+ if (isPro || _dismissed) return const SizedBox.shrink();
24
+
25
+ final sessions = ref.watch(sessionsProvider);
26
+ if (sessions.length <= kFreeTierMaxSessions) return const SizedBox.shrink();
27
+
28
+ return Material(
29
+ color: Colors.transparent,
30
+ child: Container(
31
+ margin: const EdgeInsets.fromLTRB(8, 4, 8, 0),
32
+ decoration: BoxDecoration(
33
+ color: AppColors.accent.withAlpha(230),
34
+ borderRadius: BorderRadius.circular(10),
35
+ ),
36
+ child: Padding(
37
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
38
+ child: Row(
39
+ children: [
40
+ const Icon(Icons.lock_outline, color: Colors.white, size: 18),
41
+ const SizedBox(width: 8),
42
+ Expanded(
43
+ child: Column(
44
+ crossAxisAlignment: CrossAxisAlignment.start,
45
+ mainAxisSize: MainAxisSize.min,
46
+ children: [
47
+ const Text(
48
+ 'PAILot Pro',
49
+ style: TextStyle(
50
+ color: Colors.white,
51
+ fontWeight: FontWeight.bold,
52
+ fontSize: 13,
53
+ ),
54
+ ),
55
+ const Text(
56
+ 'Unlimited sessions & persistent messages',
57
+ style: TextStyle(color: Colors.white70, fontSize: 11),
58
+ ),
59
+ ],
60
+ ),
61
+ ),
62
+ const SizedBox(width: 8),
63
+ TextButton(
64
+ onPressed: _handleRestore,
65
+ style: TextButton.styleFrom(
66
+ foregroundColor: Colors.white70,
67
+ padding: const EdgeInsets.symmetric(horizontal: 8),
68
+ minimumSize: Size.zero,
69
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
70
+ ),
71
+ child: const Text('Restore', style: TextStyle(fontSize: 11)),
72
+ ),
73
+ ElevatedButton(
74
+ onPressed: _handleUpgrade,
75
+ style: ElevatedButton.styleFrom(
76
+ backgroundColor: Colors.white,
77
+ foregroundColor: AppColors.accent,
78
+ padding:
79
+ const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
80
+ minimumSize: Size.zero,
81
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
82
+ textStyle: const TextStyle(
83
+ fontSize: 12,
84
+ fontWeight: FontWeight.bold,
85
+ ),
86
+ ),
87
+ child: const Text('Upgrade \$4.99'),
88
+ ),
89
+ const SizedBox(width: 4),
90
+ GestureDetector(
91
+ onTap: () => setState(() => _dismissed = true),
92
+ child: const Icon(Icons.close, color: Colors.white70, size: 16),
93
+ ),
94
+ ],
95
+ ),
96
+ ),
97
+ ),
98
+ );
99
+ }
100
+
101
+ Future<void> _handleUpgrade() async {
102
+ await PurchaseService.instance.purchaseFullAccess();
103
+ }
104
+
105
+ Future<void> _handleRestore() async {
106
+ await PurchaseService.instance.restorePurchases();
107
+ if (mounted) {
108
+ ScaffoldMessenger.of(context).showSnackBar(
109
+ const SnackBar(
110
+ content: Text('Checking for previous purchases...'),
111
+ duration: Duration(seconds: 2),
112
+ ),
113
+ );
114
+ }
115
+ }
116
+}
lib/widgets/session_drawer.dart
....@@ -1,6 +1,7 @@
11 import 'package:flutter/material.dart';
22
33 import '../models/session.dart';
4
+import '../services/purchase_service.dart';
45 import '../theme/app_theme.dart';
56
67 /// Side drawer showing session list with reordering, unread badges, and controls.
....@@ -8,24 +9,29 @@
89 final List<Session> sessions;
910 final String? activeSessionId;
1011 final Map<String, int> unreadCounts;
12
+ final bool isPro;
1113 final void Function(Session session) onSelect;
1214 final void Function(Session session) onRemove;
1315 final void Function(Session session, String newName) onRename;
1416 final void Function(int oldIndex, int newIndex) onReorder;
1517 final VoidCallback onNewSession;
1618 final VoidCallback onRefresh;
19
+ /// Called when the user taps the upgrade prompt in the drawer.
20
+ final VoidCallback? onUpgrade;
1721
1822 const SessionDrawer({
1923 super.key,
2024 required this.sessions,
2125 required this.activeSessionId,
2226 required this.unreadCounts,
27
+ required this.isPro,
2328 required this.onSelect,
2429 required this.onRemove,
2530 required this.onRename,
2631 required this.onReorder,
2732 required this.onNewSession,
2833 required this.onRefresh,
34
+ this.onUpgrade,
2935 });
3036
3137 @override
....@@ -73,6 +79,10 @@
7379 final session = sessions[index];
7480 final isActive = session.id == activeSessionId;
7581 final unread = unreadCounts[session.id] ?? 0;
82
+ // Sessions beyond the free limit are locked for free users.
83
+ // We still allow viewing them — just show the upgrade prompt.
84
+ final isLocked =
85
+ !isPro && index >= kFreeTierMaxSessions;
7686
7787 return Dismissible(
7888 key: ValueKey(session.id),
....@@ -111,87 +121,132 @@
111121 onDismissed: (_) => onRemove(session),
112122 child: ListTile(
113123 key: ValueKey('tile_${session.id}'),
114
- leading: Text(
115
- session.icon,
116
- style: const TextStyle(fontSize: 20),
117
- ),
124
+ leading: isLocked
125
+ ? const Icon(Icons.lock_outline,
126
+ size: 20,
127
+ color: AppColors.darkTextTertiary)
128
+ : Text(
129
+ session.icon,
130
+ style: const TextStyle(fontSize: 20),
131
+ ),
118132 title: GestureDetector(
119
- onDoubleTap: () =>
120
- _showRenameDialog(context, session),
133
+ onDoubleTap: isLocked
134
+ ? null
135
+ : () => _showRenameDialog(context, session),
121136 child: Text(
122137 session.name,
123138 style: TextStyle(
124139 fontWeight: isActive
125140 ? FontWeight.bold
126141 : FontWeight.normal,
127
- color: isActive ? AppColors.accent : null,
142
+ color: isLocked
143
+ ? AppColors.darkTextTertiary
144
+ : isActive
145
+ ? AppColors.accent
146
+ : null,
128147 ),
129148 maxLines: 1,
130149 overflow: TextOverflow.ellipsis,
131150 ),
132151 ),
133
- trailing: Row(
134
- mainAxisSize: MainAxisSize.min,
135
- children: [
136
- if (unread > 0)
137
- Container(
138
- padding: const EdgeInsets.symmetric(
139
- horizontal: 6, vertical: 2),
140
- decoration: BoxDecoration(
141
- color: AppColors.unreadBadge,
142
- borderRadius:
143
- BorderRadius.circular(10),
152
+ trailing: isLocked
153
+ ? TextButton(
154
+ onPressed: () {
155
+ Navigator.pop(context);
156
+ onUpgrade?.call();
157
+ },
158
+ style: TextButton.styleFrom(
159
+ foregroundColor: AppColors.accent,
160
+ padding: const EdgeInsets.symmetric(
161
+ horizontal: 8),
162
+ minimumSize: Size.zero,
163
+ tapTargetSize:
164
+ MaterialTapTargetSize.shrinkWrap,
144165 ),
145
- child: Text(
146
- '$unread',
147
- style: const TextStyle(
148
- color: Colors.white,
149
- fontSize: 11,
150
- fontWeight: FontWeight.bold,
166
+ child: const Text('Upgrade',
167
+ style: TextStyle(fontSize: 12)),
168
+ )
169
+ : Row(
170
+ mainAxisSize: MainAxisSize.min,
171
+ children: [
172
+ if (unread > 0)
173
+ Container(
174
+ padding: const EdgeInsets.symmetric(
175
+ horizontal: 6, vertical: 2),
176
+ decoration: BoxDecoration(
177
+ color: AppColors.unreadBadge,
178
+ borderRadius:
179
+ BorderRadius.circular(10),
180
+ ),
181
+ child: Text(
182
+ '$unread',
183
+ style: const TextStyle(
184
+ color: Colors.white,
185
+ fontSize: 11,
186
+ fontWeight: FontWeight.bold,
187
+ ),
188
+ ),
189
+ ),
190
+ const SizedBox(width: 4),
191
+ ReorderableDragStartListener(
192
+ index: index,
193
+ child: Icon(
194
+ Icons.drag_handle,
195
+ color: isDark
196
+ ? AppColors.darkTextTertiary
197
+ : Colors.grey.shade400,
198
+ size: 20,
199
+ ),
151200 ),
152
- ),
201
+ ],
153202 ),
154
- const SizedBox(width: 4),
155
- ReorderableDragStartListener(
156
- index: index,
157
- child: Icon(
158
- Icons.drag_handle,
159
- color: isDark
160
- ? AppColors.darkTextTertiary
161
- : Colors.grey.shade400,
162
- size: 20,
163
- ),
164
- ),
165
- ],
166
- ),
167203 selected: isActive,
168204 selectedTileColor: isDark
169205 ? Colors.white.withAlpha(10)
170206 : Colors.blue.withAlpha(15),
171
- onTap: () {
172
- onSelect(session);
173
- Navigator.pop(context);
174
- },
207
+ onTap: isLocked
208
+ ? () {
209
+ Navigator.pop(context);
210
+ onUpgrade?.call();
211
+ }
212
+ : () {
213
+ onSelect(session);
214
+ Navigator.pop(context);
215
+ },
175216 ),
176217 );
177218 },
178219 ),
179220 ),
180
- // New session button
221
+ // New session button (or upgrade prompt)
181222 const Divider(height: 1),
182223 Padding(
183224 padding: const EdgeInsets.all(12),
184225 child: SizedBox(
185226 width: double.infinity,
186
- child: ElevatedButton.icon(
187
- onPressed: onNewSession,
188
- icon: const Icon(Icons.add, size: 20),
189
- label: const Text('New Session'),
190
- style: ElevatedButton.styleFrom(
191
- backgroundColor: AppColors.accent,
192
- foregroundColor: Colors.white,
193
- ),
194
- ),
227
+ child: !isPro &&
228
+ sessions.length >= kFreeTierMaxSessions
229
+ ? OutlinedButton.icon(
230
+ onPressed: () {
231
+ Navigator.pop(context);
232
+ onUpgrade?.call();
233
+ },
234
+ icon: const Icon(Icons.lock_outline, size: 18),
235
+ label: const Text('Upgrade for More Sessions'),
236
+ style: OutlinedButton.styleFrom(
237
+ foregroundColor: AppColors.accent,
238
+ side: const BorderSide(color: AppColors.accent),
239
+ ),
240
+ )
241
+ : ElevatedButton.icon(
242
+ onPressed: onNewSession,
243
+ icon: const Icon(Icons.add, size: 20),
244
+ label: const Text('New Session'),
245
+ style: ElevatedButton.styleFrom(
246
+ backgroundColor: AppColors.accent,
247
+ foregroundColor: Colors.white,
248
+ ),
249
+ ),
195250 ),
196251 ),
197252 ],
macos/Flutter/GeneratedPluginRegistrant.swift
....@@ -13,6 +13,7 @@
1313 import file_selector_macos
1414 import flutter_app_badger
1515 import flutter_secure_storage_macos
16
+import in_app_purchase_storekit
1617 import push
1718 import record_macos
1819 import share_plus
....@@ -27,6 +28,7 @@
2728 FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
2829 FlutterAppBadgerPlugin.register(with: registry.registrar(forPlugin: "FlutterAppBadgerPlugin"))
2930 FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
31
+ InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
3032 PushPlugin.register(with: registry.registrar(forPlugin: "PushPlugin"))
3133 RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
3234 SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
pubspec.lock
....@@ -512,6 +512,38 @@
512512 url: "https://pub.dev"
513513 source: hosted
514514 version: "0.2.2"
515
+ in_app_purchase:
516
+ dependency: "direct main"
517
+ description:
518
+ name: in_app_purchase
519
+ sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c"
520
+ url: "https://pub.dev"
521
+ source: hosted
522
+ version: "3.2.3"
523
+ in_app_purchase_android:
524
+ dependency: transitive
525
+ description:
526
+ name: in_app_purchase_android
527
+ sha256: "634bee4734b17fe55f370f0ac07a22431a9666e0f3a870c6d20350856e8bbf71"
528
+ url: "https://pub.dev"
529
+ source: hosted
530
+ version: "0.4.0+10"
531
+ in_app_purchase_platform_interface:
532
+ dependency: transitive
533
+ description:
534
+ name: in_app_purchase_platform_interface
535
+ sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
536
+ url: "https://pub.dev"
537
+ source: hosted
538
+ version: "1.4.0"
539
+ in_app_purchase_storekit:
540
+ dependency: transitive
541
+ description:
542
+ name: in_app_purchase_storekit
543
+ sha256: "1d512809edd9f12ff88fce4596a13a18134e2499013f4d6a8894b04699363c93"
544
+ url: "https://pub.dev"
545
+ source: hosted
546
+ version: "0.4.8+1"
515547 intl:
516548 dependency: "direct main"
517549 description:
....@@ -528,6 +560,14 @@
528560 url: "https://pub.dev"
529561 source: hosted
530562 version: "0.6.7"
563
+ json_annotation:
564
+ dependency: transitive
565
+ description:
566
+ name: json_annotation
567
+ sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
568
+ url: "https://pub.dev"
569
+ source: hosted
570
+ version: "4.11.0"
531571 leak_tracker:
532572 dependency: transitive
533573 description:
pubspec.yaml
....@@ -34,6 +34,7 @@
3434 push: ^3.3.3
3535 flutter_app_badger: ^1.5.0
3636 connectivity_plus: ^7.1.0
37
+ in_app_purchase: ^3.2.3
3738
3839 dev_dependencies:
3940 flutter_test: