feat: StoreKit 2 IAP — free tier with 2 sessions and 15min message TTL
3 files added
9 files modified
| .. | .. |
|---|
| 50 | 50 | - Flutter |
|---|
| 51 | 51 | - image_picker_ios (0.0.1): |
|---|
| 52 | 52 | - Flutter |
|---|
| 53 | + - in_app_purchase_storekit (0.0.1): |
|---|
| 54 | + - Flutter |
|---|
| 55 | + - FlutterMacOS |
|---|
| 53 | 56 | - permission_handler_apple (9.3.0): |
|---|
| 54 | 57 | - Flutter |
|---|
| 55 | 58 | - push (0.0.1): |
|---|
| .. | .. |
|---|
| 79 | 82 | - flutter_app_badger (from `.symlinks/plugins/flutter_app_badger/ios`) |
|---|
| 80 | 83 | - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) |
|---|
| 81 | 84 | - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) |
|---|
| 85 | + - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`) |
|---|
| 82 | 86 | - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) |
|---|
| 83 | 87 | - push (from `.symlinks/plugins/push/darwin`) |
|---|
| 84 | 88 | - record_ios (from `.symlinks/plugins/record_ios/ios`) |
|---|
| .. | .. |
|---|
| 112 | 116 | :path: ".symlinks/plugins/flutter_secure_storage/ios" |
|---|
| 113 | 117 | image_picker_ios: |
|---|
| 114 | 118 | :path: ".symlinks/plugins/image_picker_ios/ios" |
|---|
| 119 | + in_app_purchase_storekit: |
|---|
| 120 | + :path: ".symlinks/plugins/in_app_purchase_storekit/darwin" |
|---|
| 115 | 121 | permission_handler_apple: |
|---|
| 116 | 122 | :path: ".symlinks/plugins/permission_handler_apple/ios" |
|---|
| 117 | 123 | push: |
|---|
| .. | .. |
|---|
| 137 | 143 | flutter_app_badger: 16b371e989d04cd265df85be2c3851b49cb68d18 |
|---|
| 138 | 144 | flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 |
|---|
| 139 | 145 | image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 |
|---|
| 146 | + in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8 |
|---|
| 140 | 147 | permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d |
|---|
| 141 | 148 | push: 91373ae39c5341c6de6adefa3fda7f7287d646bf |
|---|
| 142 | 149 | record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 |
|---|
| .. | .. |
|---|
| 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 | +} |
|---|
| .. | .. |
|---|
| 7 | 7 | import 'theme/app_theme.dart'; |
|---|
| 8 | 8 | import 'providers/providers.dart'; |
|---|
| 9 | 9 | import 'services/audio_service.dart'; |
|---|
| 10 | +import 'services/purchase_service.dart'; |
|---|
| 10 | 11 | |
|---|
| 11 | 12 | void main() async { |
|---|
| 12 | 13 | WidgetsFlutterBinding.ensureInitialized(); |
|---|
| .. | .. |
|---|
| 23 | 24 | // Initialize audio service |
|---|
| 24 | 25 | AudioService.init(); |
|---|
| 25 | 26 | |
|---|
| 27 | + // Initialize purchase service (loads cached status + starts StoreKit listener) |
|---|
| 28 | + await PurchaseService.instance.initialize(); |
|---|
| 29 | + |
|---|
| 26 | 30 | runApp( |
|---|
| 27 | 31 | ProviderScope( |
|---|
| 28 | 32 | overrides: [ |
|---|
| 29 | 33 | themeModeProvider.overrideWith((ref) => themeMode), |
|---|
| 34 | + // Seed isProProvider from StoreKit cache so first frame is correct. |
|---|
| 35 | + isProProvider.overrideWith((ref) => PurchaseService.instance.isPro), |
|---|
| 30 | 36 | ], |
|---|
| 31 | 37 | child: const PAILotApp(), |
|---|
| 32 | 38 | ), |
|---|
| .. | .. |
|---|
| 47 | 53 | void initState() { |
|---|
| 48 | 54 | super.initState(); |
|---|
| 49 | 55 | _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; |
|---|
| 50 | 68 | } |
|---|
| 51 | 69 | |
|---|
| 52 | 70 | @override |
|---|
| .. | .. |
|---|
| 211 | 211 | // ChatScreen sets this when MQTT is initialized; NavigateScreen reads it. |
|---|
| 212 | 212 | // Using a Riverpod provider eliminates the stale static reference risk. |
|---|
| 213 | 213 | 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); |
|---|
| .. | .. |
|---|
| 23 | 23 | import '../services/navigate_notifier.dart'; |
|---|
| 24 | 24 | import '../services/push_service.dart'; |
|---|
| 25 | 25 | import '../theme/app_theme.dart'; |
|---|
| 26 | +import '../services/purchase_service.dart'; |
|---|
| 26 | 27 | import '../widgets/command_bar.dart'; |
|---|
| 27 | 28 | import '../widgets/input_bar.dart'; |
|---|
| 28 | 29 | import '../widgets/message_bubble.dart'; |
|---|
| 30 | +import '../widgets/paywall_banner.dart'; |
|---|
| 29 | 31 | import '../widgets/session_drawer.dart'; |
|---|
| 30 | 32 | import '../widgets/status_dot.dart'; |
|---|
| 31 | 33 | import '../widgets/toast_overlay.dart'; |
|---|
| .. | .. |
|---|
| 1332 | 1334 | _sendCommand('create'); |
|---|
| 1333 | 1335 | } |
|---|
| 1334 | 1336 | |
|---|
| 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 | + |
|---|
| 1335 | 1342 | void _handleSessionRename(Session session, String newName) { |
|---|
| 1336 | 1343 | _sendCommand('rename', {'sessionId': session.id, 'name': newName}); |
|---|
| 1337 | 1344 | final sessions = ref.read(sessionsProvider); |
|---|
| .. | .. |
|---|
| 1385 | 1392 | |
|---|
| 1386 | 1393 | @override |
|---|
| 1387 | 1394 | 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(); |
|---|
| 1389 | 1406 | final wsStatus = ref.watch(wsStatusProvider); |
|---|
| 1390 | 1407 | final isTyping = ref.watch(isTypingProvider); |
|---|
| 1391 | 1408 | final connectionDetail = ref.watch(connectionDetailProvider); |
|---|
| .. | .. |
|---|
| 1452 | 1469 | sessions: sessions, |
|---|
| 1453 | 1470 | activeSessionId: activeSession?.id, |
|---|
| 1454 | 1471 | unreadCounts: unreadCounts, |
|---|
| 1472 | + isPro: ref.watch(isProProvider), |
|---|
| 1455 | 1473 | onSelect: (s) => _switchSession(s.id), |
|---|
| 1456 | 1474 | onRemove: _handleSessionRemove, |
|---|
| 1457 | 1475 | onRename: _handleSessionRename, |
|---|
| 1458 | 1476 | onReorder: _handleSessionReorder, |
|---|
| 1459 | 1477 | onNewSession: _handleNewSession, |
|---|
| 1460 | 1478 | onRefresh: _refreshSessions, |
|---|
| 1479 | + onUpgrade: _handleUpgrade, |
|---|
| 1461 | 1480 | ), |
|---|
| 1462 | 1481 | body: Column( |
|---|
| 1463 | 1482 | children: [ |
|---|
| 1483 | + const PaywallBanner(), |
|---|
| 1464 | 1484 | Expanded( |
|---|
| 1465 | 1485 | child: ListView.builder( |
|---|
| 1466 | 1486 | controller: _scrollController, |
|---|
| .. | .. |
|---|
| 5 | 5 | import '../models/server_config.dart'; |
|---|
| 6 | 6 | import '../providers/providers.dart'; |
|---|
| 7 | 7 | import '../services/mqtt_service.dart' show ConnectionStatus; |
|---|
| 8 | +import '../services/purchase_service.dart'; |
|---|
| 8 | 9 | import '../services/wol_service.dart'; |
|---|
| 9 | 10 | import '../theme/app_theme.dart'; |
|---|
| 10 | 11 | import '../widgets/status_dot.dart'; |
|---|
| .. | .. |
|---|
| 25 | 26 | late final TextEditingController _macController; |
|---|
| 26 | 27 | late final TextEditingController _mqttTokenController; |
|---|
| 27 | 28 | bool _isWaking = false; |
|---|
| 29 | + bool _iapLoading = false; |
|---|
| 28 | 30 | |
|---|
| 29 | 31 | @override |
|---|
| 30 | 32 | void initState() { |
|---|
| 31 | 33 | super.initState(); |
|---|
| 34 | + PurchaseService.instance.addListener(_onPurchaseChanged); |
|---|
| 32 | 35 | final config = ref.read(serverConfigProvider); |
|---|
| 33 | 36 | _localHostController = |
|---|
| 34 | 37 | TextEditingController(text: config?.localHost ?? ''); |
|---|
| .. | .. |
|---|
| 46 | 49 | |
|---|
| 47 | 50 | @override |
|---|
| 48 | 51 | void dispose() { |
|---|
| 52 | + PurchaseService.instance.removeListener(_onPurchaseChanged); |
|---|
| 49 | 53 | _localHostController.dispose(); |
|---|
| 50 | 54 | _vpnHostController.dispose(); |
|---|
| 51 | 55 | _remoteHostController.dispose(); |
|---|
| .. | .. |
|---|
| 53 | 57 | _macController.dispose(); |
|---|
| 54 | 58 | _mqttTokenController.dispose(); |
|---|
| 55 | 59 | 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 | + } |
|---|
| 56 | 99 | } |
|---|
| 57 | 100 | |
|---|
| 58 | 101 | Future<void> _save() async { |
|---|
| .. | .. |
|---|
| 328 | 371 | icon: const Icon(Icons.shield_outlined), |
|---|
| 329 | 372 | label: const Text('Reset Server Trust'), |
|---|
| 330 | 373 | ), |
|---|
| 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 | + ), |
|---|
| 331 | 457 | const SizedBox(height: 12), |
|---|
| 332 | 458 | ], |
|---|
| 333 | 459 | ), |
|---|
| .. | .. |
|---|
| 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 | +} |
|---|
| .. | .. |
|---|
| 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 | +} |
|---|
| .. | .. |
|---|
| 1 | 1 | import 'package:flutter/material.dart'; |
|---|
| 2 | 2 | |
|---|
| 3 | 3 | import '../models/session.dart'; |
|---|
| 4 | +import '../services/purchase_service.dart'; |
|---|
| 4 | 5 | import '../theme/app_theme.dart'; |
|---|
| 5 | 6 | |
|---|
| 6 | 7 | /// Side drawer showing session list with reordering, unread badges, and controls. |
|---|
| .. | .. |
|---|
| 8 | 9 | final List<Session> sessions; |
|---|
| 9 | 10 | final String? activeSessionId; |
|---|
| 10 | 11 | final Map<String, int> unreadCounts; |
|---|
| 12 | + final bool isPro; |
|---|
| 11 | 13 | final void Function(Session session) onSelect; |
|---|
| 12 | 14 | final void Function(Session session) onRemove; |
|---|
| 13 | 15 | final void Function(Session session, String newName) onRename; |
|---|
| 14 | 16 | final void Function(int oldIndex, int newIndex) onReorder; |
|---|
| 15 | 17 | final VoidCallback onNewSession; |
|---|
| 16 | 18 | final VoidCallback onRefresh; |
|---|
| 19 | + /// Called when the user taps the upgrade prompt in the drawer. |
|---|
| 20 | + final VoidCallback? onUpgrade; |
|---|
| 17 | 21 | |
|---|
| 18 | 22 | const SessionDrawer({ |
|---|
| 19 | 23 | super.key, |
|---|
| 20 | 24 | required this.sessions, |
|---|
| 21 | 25 | required this.activeSessionId, |
|---|
| 22 | 26 | required this.unreadCounts, |
|---|
| 27 | + required this.isPro, |
|---|
| 23 | 28 | required this.onSelect, |
|---|
| 24 | 29 | required this.onRemove, |
|---|
| 25 | 30 | required this.onRename, |
|---|
| 26 | 31 | required this.onReorder, |
|---|
| 27 | 32 | required this.onNewSession, |
|---|
| 28 | 33 | required this.onRefresh, |
|---|
| 34 | + this.onUpgrade, |
|---|
| 29 | 35 | }); |
|---|
| 30 | 36 | |
|---|
| 31 | 37 | @override |
|---|
| .. | .. |
|---|
| 73 | 79 | final session = sessions[index]; |
|---|
| 74 | 80 | final isActive = session.id == activeSessionId; |
|---|
| 75 | 81 | 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; |
|---|
| 76 | 86 | |
|---|
| 77 | 87 | return Dismissible( |
|---|
| 78 | 88 | key: ValueKey(session.id), |
|---|
| .. | .. |
|---|
| 111 | 121 | onDismissed: (_) => onRemove(session), |
|---|
| 112 | 122 | child: ListTile( |
|---|
| 113 | 123 | 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 | + ), |
|---|
| 118 | 132 | title: GestureDetector( |
|---|
| 119 | | - onDoubleTap: () => |
|---|
| 120 | | - _showRenameDialog(context, session), |
|---|
| 133 | + onDoubleTap: isLocked |
|---|
| 134 | + ? null |
|---|
| 135 | + : () => _showRenameDialog(context, session), |
|---|
| 121 | 136 | child: Text( |
|---|
| 122 | 137 | session.name, |
|---|
| 123 | 138 | style: TextStyle( |
|---|
| 124 | 139 | fontWeight: isActive |
|---|
| 125 | 140 | ? FontWeight.bold |
|---|
| 126 | 141 | : FontWeight.normal, |
|---|
| 127 | | - color: isActive ? AppColors.accent : null, |
|---|
| 142 | + color: isLocked |
|---|
| 143 | + ? AppColors.darkTextTertiary |
|---|
| 144 | + : isActive |
|---|
| 145 | + ? AppColors.accent |
|---|
| 146 | + : null, |
|---|
| 128 | 147 | ), |
|---|
| 129 | 148 | maxLines: 1, |
|---|
| 130 | 149 | overflow: TextOverflow.ellipsis, |
|---|
| 131 | 150 | ), |
|---|
| 132 | 151 | ), |
|---|
| 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, |
|---|
| 144 | 165 | ), |
|---|
| 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 | + ), |
|---|
| 151 | 200 | ), |
|---|
| 152 | | - ), |
|---|
| 201 | + ], |
|---|
| 153 | 202 | ), |
|---|
| 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 | | - ), |
|---|
| 167 | 203 | selected: isActive, |
|---|
| 168 | 204 | selectedTileColor: isDark |
|---|
| 169 | 205 | ? Colors.white.withAlpha(10) |
|---|
| 170 | 206 | : 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 | + }, |
|---|
| 175 | 216 | ), |
|---|
| 176 | 217 | ); |
|---|
| 177 | 218 | }, |
|---|
| 178 | 219 | ), |
|---|
| 179 | 220 | ), |
|---|
| 180 | | - // New session button |
|---|
| 221 | + // New session button (or upgrade prompt) |
|---|
| 181 | 222 | const Divider(height: 1), |
|---|
| 182 | 223 | Padding( |
|---|
| 183 | 224 | padding: const EdgeInsets.all(12), |
|---|
| 184 | 225 | child: SizedBox( |
|---|
| 185 | 226 | 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 | + ), |
|---|
| 195 | 250 | ), |
|---|
| 196 | 251 | ), |
|---|
| 197 | 252 | ], |
|---|
| .. | .. |
|---|
| 13 | 13 | import file_selector_macos |
|---|
| 14 | 14 | import flutter_app_badger |
|---|
| 15 | 15 | import flutter_secure_storage_macos |
|---|
| 16 | +import in_app_purchase_storekit |
|---|
| 16 | 17 | import push |
|---|
| 17 | 18 | import record_macos |
|---|
| 18 | 19 | import share_plus |
|---|
| .. | .. |
|---|
| 27 | 28 | FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) |
|---|
| 28 | 29 | FlutterAppBadgerPlugin.register(with: registry.registrar(forPlugin: "FlutterAppBadgerPlugin")) |
|---|
| 29 | 30 | FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) |
|---|
| 31 | + InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) |
|---|
| 30 | 32 | PushPlugin.register(with: registry.registrar(forPlugin: "PushPlugin")) |
|---|
| 31 | 33 | RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) |
|---|
| 32 | 34 | SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) |
|---|
| .. | .. |
|---|
| 512 | 512 | url: "https://pub.dev" |
|---|
| 513 | 513 | source: hosted |
|---|
| 514 | 514 | 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" |
|---|
| 515 | 547 | intl: |
|---|
| 516 | 548 | dependency: "direct main" |
|---|
| 517 | 549 | description: |
|---|
| .. | .. |
|---|
| 528 | 560 | url: "https://pub.dev" |
|---|
| 529 | 561 | source: hosted |
|---|
| 530 | 562 | 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" |
|---|
| 531 | 571 | leak_tracker: |
|---|
| 532 | 572 | dependency: transitive |
|---|
| 533 | 573 | description: |
|---|
| .. | .. |
|---|
| 34 | 34 | push: ^3.3.3 |
|---|
| 35 | 35 | flutter_app_badger: ^1.5.0 |
|---|
| 36 | 36 | connectivity_plus: ^7.1.0 |
|---|
| 37 | + in_app_purchase: ^3.2.3 |
|---|
| 37 | 38 | |
|---|
| 38 | 39 | dev_dependencies: |
|---|
| 39 | 40 | flutter_test: |
|---|