From 98e5695f9c77c594a103e9e81128798d41bae46a Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Wed, 01 Apr 2026 18:52:33 +0200
Subject: [PATCH] feat: StoreKit 2 IAP — free tier with 2 sessions and 15min message TTL
---
lib/screens/settings_screen.dart | 126 +++++++++
lib/main.dart | 18 +
lib/providers/providers.dart | 6
ios/Podfile.lock | 7
macos/Flutter/GeneratedPluginRegistrant.swift | 2
ios/Runner/Configuration.storekit | 30 ++
lib/widgets/session_drawer.dart | 159 ++++++++----
lib/widgets/paywall_banner.dart | 116 ++++++++
lib/services/purchase_service.dart | 197 +++++++++++++++
pubspec.lock | 40 +++
lib/screens/chat_screen.dart | 22 +
pubspec.yaml | 1
12 files changed, 671 insertions(+), 53 deletions(-)
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index f5d7ae0..12a2822 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -50,6 +50,9 @@
- Flutter
- image_picker_ios (0.0.1):
- Flutter
+ - in_app_purchase_storekit (0.0.1):
+ - Flutter
+ - FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- push (0.0.1):
@@ -79,6 +82,7 @@
- flutter_app_badger (from `.symlinks/plugins/flutter_app_badger/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
+ - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- push (from `.symlinks/plugins/push/darwin`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
@@ -112,6 +116,8 @@
:path: ".symlinks/plugins/flutter_secure_storage/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
+ in_app_purchase_storekit:
+ :path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
push:
@@ -137,6 +143,7 @@
flutter_app_badger: 16b371e989d04cd265df85be2c3851b49cb68d18
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
+ in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
push: 91373ae39c5341c6de6adefa3fda7f7287d646bf
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
diff --git a/ios/Runner/Configuration.storekit b/ios/Runner/Configuration.storekit
new file mode 100644
index 0000000..57a85f8
--- /dev/null
+++ b/ios/Runner/Configuration.storekit
@@ -0,0 +1,30 @@
+{
+ "identifier" : "2F3A87BA-1234-4CDE-ABCD-9876543210AB",
+ "nonConsumableProducts" : [
+ {
+ "displayPrice" : "4.99",
+ "familySharable" : false,
+ "internalID" : "F3A9827C-5678-4BCD-EFAB-0123456789CD",
+ "localizations" : [
+ {
+ "description" : "Unlock unlimited sessions and persistent message history in PAILot.",
+ "displayName" : "PAILot Pro",
+ "locale" : "en_US"
+ }
+ ],
+ "productID" : "com.tekmidian.pailot.fullaccess",
+ "referenceName" : "PAILot Pro Full Access",
+ "type" : "NonConsumable"
+ }
+ ],
+ "settings" : {
+ "_enableAskToBuyInterruptionForTesting" : false,
+ "_enableBackgroundDeliveryForTesting" : true,
+ "_enableFamilySharingForTesting" : false,
+ "_storeKitErrors" : []
+ },
+ "version" : {
+ "major" : 2,
+ "minor" : 0
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index 34def81..5bab6bb 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -7,6 +7,7 @@
import 'theme/app_theme.dart';
import 'providers/providers.dart';
import 'services/audio_service.dart';
+import 'services/purchase_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -23,10 +24,15 @@
// Initialize audio service
AudioService.init();
+ // Initialize purchase service (loads cached status + starts StoreKit listener)
+ await PurchaseService.instance.initialize();
+
runApp(
ProviderScope(
overrides: [
themeModeProvider.overrideWith((ref) => themeMode),
+ // Seed isProProvider from StoreKit cache so first frame is correct.
+ isProProvider.overrideWith((ref) => PurchaseService.instance.isPro),
],
child: const PAILotApp(),
),
@@ -47,6 +53,18 @@
void initState() {
super.initState();
_router = createRouter();
+ // Keep isProProvider in sync whenever PurchaseService notifies a change.
+ PurchaseService.instance.addListener(_onPurchaseChanged);
+ }
+
+ @override
+ void dispose() {
+ PurchaseService.instance.removeListener(_onPurchaseChanged);
+ super.dispose();
+ }
+
+ void _onPurchaseChanged() {
+ ref.read(isProProvider.notifier).state = PurchaseService.instance.isPro;
}
@override
diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart
index 6393124..49022c4 100644
--- a/lib/providers/providers.dart
+++ b/lib/providers/providers.dart
@@ -211,3 +211,9 @@
// ChatScreen sets this when MQTT is initialized; NavigateScreen reads it.
// Using a Riverpod provider eliminates the stale static reference risk.
final navigateNotifierProvider = StateProvider<NavigateNotifier?>((ref) => null);
+
+// --- Pro / Purchase Status ---
+
+/// Whether the user has purchased PAILot Pro (full access).
+/// Updated by PurchaseService after StoreKit verification.
+final isProProvider = StateProvider<bool>((ref) => false);
diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index f51862a..e1aa9db 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -23,9 +23,11 @@
import '../services/navigate_notifier.dart';
import '../services/push_service.dart';
import '../theme/app_theme.dart';
+import '../services/purchase_service.dart';
import '../widgets/command_bar.dart';
import '../widgets/input_bar.dart';
import '../widgets/message_bubble.dart';
+import '../widgets/paywall_banner.dart';
import '../widgets/session_drawer.dart';
import '../widgets/status_dot.dart';
import '../widgets/toast_overlay.dart';
@@ -1332,6 +1334,11 @@
_sendCommand('create');
}
+ /// Called when the user taps an upgrade CTA in the drawer or paywall banner.
+ Future<void> _handleUpgrade() async {
+ await PurchaseService.instance.purchaseFullAccess();
+ }
+
void _handleSessionRename(Session session, String newName) {
_sendCommand('rename', {'sessionId': session.id, 'name': newName});
final sessions = ref.read(sessionsProvider);
@@ -1385,7 +1392,17 @@
@override
Widget build(BuildContext context) {
- final messages = ref.watch(messagesProvider);
+ final allMessages = ref.watch(messagesProvider);
+ final isPro = ref.watch(isProProvider);
+ // Free tier: filter out messages older than 15 minutes on display.
+ // Storage is unchanged — messages reappear if the user later upgrades.
+ final messages = isPro
+ ? allMessages
+ : allMessages.where((m) {
+ final ts = DateTime.fromMillisecondsSinceEpoch(m.timestamp);
+ final age = DateTime.now().difference(ts);
+ return age <= kFreeTierMessageTtl;
+ }).toList();
final wsStatus = ref.watch(wsStatusProvider);
final isTyping = ref.watch(isTypingProvider);
final connectionDetail = ref.watch(connectionDetailProvider);
@@ -1452,15 +1469,18 @@
sessions: sessions,
activeSessionId: activeSession?.id,
unreadCounts: unreadCounts,
+ isPro: ref.watch(isProProvider),
onSelect: (s) => _switchSession(s.id),
onRemove: _handleSessionRemove,
onRename: _handleSessionRename,
onReorder: _handleSessionReorder,
onNewSession: _handleNewSession,
onRefresh: _refreshSessions,
+ onUpgrade: _handleUpgrade,
),
body: Column(
children: [
+ const PaywallBanner(),
Expanded(
child: ListView.builder(
controller: _scrollController,
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 840db79..593b5dd 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -5,6 +5,7 @@
import '../models/server_config.dart';
import '../providers/providers.dart';
import '../services/mqtt_service.dart' show ConnectionStatus;
+import '../services/purchase_service.dart';
import '../services/wol_service.dart';
import '../theme/app_theme.dart';
import '../widgets/status_dot.dart';
@@ -25,10 +26,12 @@
late final TextEditingController _macController;
late final TextEditingController _mqttTokenController;
bool _isWaking = false;
+ bool _iapLoading = false;
@override
void initState() {
super.initState();
+ PurchaseService.instance.addListener(_onPurchaseChanged);
final config = ref.read(serverConfigProvider);
_localHostController =
TextEditingController(text: config?.localHost ?? '');
@@ -46,6 +49,7 @@
@override
void dispose() {
+ PurchaseService.instance.removeListener(_onPurchaseChanged);
_localHostController.dispose();
_vpnHostController.dispose();
_remoteHostController.dispose();
@@ -53,6 +57,45 @@
_macController.dispose();
_mqttTokenController.dispose();
super.dispose();
+ }
+
+ void _onPurchaseChanged() {
+ if (!mounted) return;
+ final isPro = PurchaseService.instance.isPro;
+ ref.read(isProProvider.notifier).state = isPro;
+ setState(() => _iapLoading = PurchaseService.instance.isLoading);
+ final error = PurchaseService.instance.errorMessage;
+ if (error != null && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(error)),
+ );
+ }
+ if (isPro && mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('PAILot Pro activated. Enjoy unlimited sessions!'),
+ duration: Duration(seconds: 3),
+ ),
+ );
+ }
+ }
+
+ Future<void> _handleUpgrade() async {
+ setState(() => _iapLoading = true);
+ await PurchaseService.instance.purchaseFullAccess();
+ }
+
+ Future<void> _handleRestore() async {
+ setState(() => _iapLoading = true);
+ await PurchaseService.instance.restorePurchases();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Checking for previous purchases...'),
+ duration: Duration(seconds: 2),
+ ),
+ );
+ }
}
Future<void> _save() async {
@@ -328,6 +371,89 @@
icon: const Icon(Icons.shield_outlined),
label: const Text('Reset Server Trust'),
),
+ const SizedBox(height: 24),
+
+ // --- PAILot Pro ---
+ const Divider(),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ const Icon(Icons.star, size: 18),
+ const SizedBox(width: 8),
+ Text(
+ 'PAILot Pro',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ const Spacer(),
+ Consumer(
+ builder: (ctx, ref, _) {
+ final isPro = ref.watch(isProProvider);
+ return Chip(
+ label: Text(
+ isPro ? 'Active' : 'Free Tier',
+ style: TextStyle(
+ fontSize: 11,
+ color: isPro ? Colors.white : null,
+ ),
+ ),
+ backgroundColor: isPro ? AppColors.accent : null,
+ padding: EdgeInsets.zero,
+ visualDensity: VisualDensity.compact,
+ );
+ },
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Free: 2 sessions, messages expire after 15 min\n'
+ 'Pro: unlimited sessions, messages persist forever',
+ style: Theme.of(context)
+ .textTheme
+ .bodySmall
+ ?.copyWith(color: Colors.grey.shade500),
+ ),
+ const SizedBox(height: 12),
+ Consumer(
+ builder: (ctx, ref, _) {
+ final isPro = ref.watch(isProProvider);
+ if (isPro) {
+ return const Center(
+ child: Text(
+ 'Full access active',
+ style: TextStyle(color: AppColors.accent),
+ ),
+ );
+ }
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ ElevatedButton.icon(
+ onPressed: _iapLoading ? null : _handleUpgrade,
+ icon: _iapLoading
+ ? const SizedBox(
+ width: 16,
+ height: 16,
+ child:
+ CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.upgrade),
+ label: const Text('Upgrade to Pro — \$4.99'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppColors.accent,
+ foregroundColor: Colors.white,
+ ),
+ ),
+ const SizedBox(height: 8),
+ OutlinedButton.icon(
+ onPressed: _iapLoading ? null : _handleRestore,
+ icon: const Icon(Icons.restore),
+ label: const Text('Restore Purchase'),
+ ),
+ ],
+ );
+ },
+ ),
const SizedBox(height: 12),
],
),
diff --git a/lib/services/purchase_service.dart b/lib/services/purchase_service.dart
new file mode 100644
index 0000000..ff94e4e
--- /dev/null
+++ b/lib/services/purchase_service.dart
@@ -0,0 +1,197 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:in_app_purchase/in_app_purchase.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// Product ID for the one-time full-access purchase.
+const String kFullAccessProductId = 'com.tekmidian.pailot.fullaccess';
+
+/// Maximum sessions allowed on the free tier.
+const int kFreeTierMaxSessions = 2;
+
+/// Maximum message age for free-tier users (15 minutes).
+const Duration kFreeTierMessageTtl = Duration(minutes: 15);
+
+/// Shared preference key for caching purchase status locally.
+const String _kProCacheKey = 'pailot_is_pro';
+
+/// Service that manages the StoreKit 2 / in_app_purchase lifecycle.
+///
+/// Usage:
+/// final svc = PurchaseService();
+/// await svc.initialize();
+/// svc.addListener(() { ... });
+/// bool pro = svc.isPro;
+///
+/// Call [dispose] when done.
+class PurchaseService extends ChangeNotifier {
+ PurchaseService._();
+
+ static final PurchaseService instance = PurchaseService._();
+
+ bool _isPro = false;
+ bool _isLoading = false;
+ String? _errorMessage;
+
+ StreamSubscription<List<PurchaseDetails>>? _subscription;
+
+ /// Whether the user has purchased full access.
+ bool get isPro => _isPro;
+
+ /// True while a purchase or restore operation is in progress.
+ bool get isLoading => _isLoading;
+
+ /// Non-null if the last operation produced an error message.
+ String? get errorMessage => _errorMessage;
+
+ // ---------------------------------------------------------------------------
+ // Lifecycle
+ // ---------------------------------------------------------------------------
+
+ /// Initialize the service. Call once at app startup.
+ Future<void> initialize() async {
+ // Restore cached value immediately so UI doesn't flicker.
+ final prefs = await SharedPreferences.getInstance();
+ _isPro = prefs.getBool(_kProCacheKey) ?? false;
+ notifyListeners();
+
+ // Listen for ongoing purchase updates.
+ final purchaseUpdated = InAppPurchase.instance.purchaseStream;
+ _subscription = purchaseUpdated.listen(
+ _handlePurchaseUpdates,
+ onError: (Object err) {
+ _errorMessage = err.toString();
+ notifyListeners();
+ },
+ );
+
+ // Verify with StoreKit on each launch (catches refunds / family sharing).
+ await restorePurchases(silent: true);
+ }
+
+ @override
+ void dispose() {
+ _subscription?.cancel();
+ super.dispose();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Public API
+ // ---------------------------------------------------------------------------
+
+ /// Initiate the purchase flow for full access.
+ Future<void> purchaseFullAccess() async {
+ _errorMessage = null;
+ _isLoading = true;
+ notifyListeners();
+
+ try {
+ final bool available = await InAppPurchase.instance.isAvailable();
+ if (!available) {
+ _errorMessage = 'Store not available. Check your internet connection.';
+ _isLoading = false;
+ notifyListeners();
+ return;
+ }
+
+ final ProductDetailsResponse response = await InAppPurchase.instance
+ .queryProductDetails({kFullAccessProductId});
+
+ if (response.error != null || response.productDetails.isEmpty) {
+ _errorMessage =
+ 'Product not found. Please try again later.';
+ _isLoading = false;
+ notifyListeners();
+ return;
+ }
+
+ final ProductDetails product = response.productDetails.first;
+ final PurchaseParam param = PurchaseParam(productDetails: product);
+ await InAppPurchase.instance.buyNonConsumable(purchaseParam: param);
+ // Result arrives via purchaseStream — _isLoading cleared there.
+ } catch (e) {
+ _errorMessage = 'Purchase failed: $e';
+ _isLoading = false;
+ notifyListeners();
+ }
+ }
+
+ /// Restore previously completed purchases (also called on app launch).
+ Future<void> restorePurchases({bool silent = false}) async {
+ if (!silent) {
+ _errorMessage = null;
+ _isLoading = true;
+ notifyListeners();
+ }
+
+ try {
+ await InAppPurchase.instance.restorePurchases();
+ // Results arrive asynchronously via purchaseStream.
+ // For non-silent restores _isLoading is cleared there.
+ if (silent) {
+ // Give the stream a moment to deliver any results.
+ await Future<void>.delayed(const Duration(seconds: 2));
+ }
+ } catch (e) {
+ if (!silent) {
+ _errorMessage = 'Restore failed: $e';
+ _isLoading = false;
+ notifyListeners();
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Internal
+ // ---------------------------------------------------------------------------
+
+ Future<void> _handlePurchaseUpdates(
+ List<PurchaseDetails> purchases) async {
+ for (final PurchaseDetails purchase in purchases) {
+ if (purchase.productID != kFullAccessProductId) continue;
+
+ switch (purchase.status) {
+ case PurchaseStatus.purchased:
+ case PurchaseStatus.restored:
+ await _deliverPurchase(purchase);
+ break;
+
+ case PurchaseStatus.error:
+ _errorMessage = purchase.error?.message ?? 'Purchase failed.';
+ _isLoading = false;
+ notifyListeners();
+ break;
+
+ case PurchaseStatus.canceled:
+ _isLoading = false;
+ notifyListeners();
+ break;
+
+ case PurchaseStatus.pending:
+ // Show loading while pending (e.g. Ask to Buy).
+ _isLoading = true;
+ notifyListeners();
+ break;
+ }
+
+ // Complete the transaction to prevent it from being re-delivered.
+ if (purchase.pendingCompletePurchase) {
+ await InAppPurchase.instance.completePurchase(purchase);
+ }
+ }
+ }
+
+ Future<void> _deliverPurchase(PurchaseDetails purchase) async {
+ _isPro = true;
+ _isLoading = false;
+ _errorMessage = null;
+
+ // Persist so the next app launch restores from cache quickly.
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(_kProCacheKey, true);
+
+ debugPrint('[Purchase] Full access granted for ${purchase.productID}');
+ notifyListeners();
+ }
+}
diff --git a/lib/widgets/paywall_banner.dart b/lib/widgets/paywall_banner.dart
new file mode 100644
index 0000000..605f581
--- /dev/null
+++ b/lib/widgets/paywall_banner.dart
@@ -0,0 +1,116 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../providers/providers.dart';
+import '../services/purchase_service.dart';
+import '../theme/app_theme.dart';
+
+/// Dismissible banner shown at the top of the chat screen when a free-tier
+/// limit has been reached. Tapping "Upgrade" initiates the IAP flow.
+class PaywallBanner extends ConsumerStatefulWidget {
+ const PaywallBanner({super.key});
+
+ @override
+ ConsumerState<PaywallBanner> createState() => _PaywallBannerState();
+}
+
+class _PaywallBannerState extends ConsumerState<PaywallBanner> {
+ bool _dismissed = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final isPro = ref.watch(isProProvider);
+ if (isPro || _dismissed) return const SizedBox.shrink();
+
+ final sessions = ref.watch(sessionsProvider);
+ if (sessions.length <= kFreeTierMaxSessions) return const SizedBox.shrink();
+
+ return Material(
+ color: Colors.transparent,
+ child: Container(
+ margin: const EdgeInsets.fromLTRB(8, 4, 8, 0),
+ decoration: BoxDecoration(
+ color: AppColors.accent.withAlpha(230),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ child: Row(
+ children: [
+ const Icon(Icons.lock_outline, color: Colors.white, size: 18),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Text(
+ 'PAILot Pro',
+ style: TextStyle(
+ color: Colors.white,
+ fontWeight: FontWeight.bold,
+ fontSize: 13,
+ ),
+ ),
+ const Text(
+ 'Unlimited sessions & persistent messages',
+ style: TextStyle(color: Colors.white70, fontSize: 11),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ TextButton(
+ onPressed: _handleRestore,
+ style: TextButton.styleFrom(
+ foregroundColor: Colors.white70,
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ minimumSize: Size.zero,
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ ),
+ child: const Text('Restore', style: TextStyle(fontSize: 11)),
+ ),
+ ElevatedButton(
+ onPressed: _handleUpgrade,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.white,
+ foregroundColor: AppColors.accent,
+ padding:
+ const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ minimumSize: Size.zero,
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ textStyle: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ child: const Text('Upgrade \$4.99'),
+ ),
+ const SizedBox(width: 4),
+ GestureDetector(
+ onTap: () => setState(() => _dismissed = true),
+ child: const Icon(Icons.close, color: Colors.white70, size: 16),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Future<void> _handleUpgrade() async {
+ await PurchaseService.instance.purchaseFullAccess();
+ }
+
+ Future<void> _handleRestore() async {
+ await PurchaseService.instance.restorePurchases();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Checking for previous purchases...'),
+ duration: Duration(seconds: 2),
+ ),
+ );
+ }
+ }
+}
diff --git a/lib/widgets/session_drawer.dart b/lib/widgets/session_drawer.dart
index f3a5178..a9f6cd8 100644
--- a/lib/widgets/session_drawer.dart
+++ b/lib/widgets/session_drawer.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../models/session.dart';
+import '../services/purchase_service.dart';
import '../theme/app_theme.dart';
/// Side drawer showing session list with reordering, unread badges, and controls.
@@ -8,24 +9,29 @@
final List<Session> sessions;
final String? activeSessionId;
final Map<String, int> unreadCounts;
+ final bool isPro;
final void Function(Session session) onSelect;
final void Function(Session session) onRemove;
final void Function(Session session, String newName) onRename;
final void Function(int oldIndex, int newIndex) onReorder;
final VoidCallback onNewSession;
final VoidCallback onRefresh;
+ /// Called when the user taps the upgrade prompt in the drawer.
+ final VoidCallback? onUpgrade;
const SessionDrawer({
super.key,
required this.sessions,
required this.activeSessionId,
required this.unreadCounts,
+ required this.isPro,
required this.onSelect,
required this.onRemove,
required this.onRename,
required this.onReorder,
required this.onNewSession,
required this.onRefresh,
+ this.onUpgrade,
});
@override
@@ -73,6 +79,10 @@
final session = sessions[index];
final isActive = session.id == activeSessionId;
final unread = unreadCounts[session.id] ?? 0;
+ // Sessions beyond the free limit are locked for free users.
+ // We still allow viewing them — just show the upgrade prompt.
+ final isLocked =
+ !isPro && index >= kFreeTierMaxSessions;
return Dismissible(
key: ValueKey(session.id),
@@ -111,87 +121,132 @@
onDismissed: (_) => onRemove(session),
child: ListTile(
key: ValueKey('tile_${session.id}'),
- leading: Text(
- session.icon,
- style: const TextStyle(fontSize: 20),
- ),
+ leading: isLocked
+ ? const Icon(Icons.lock_outline,
+ size: 20,
+ color: AppColors.darkTextTertiary)
+ : Text(
+ session.icon,
+ style: const TextStyle(fontSize: 20),
+ ),
title: GestureDetector(
- onDoubleTap: () =>
- _showRenameDialog(context, session),
+ onDoubleTap: isLocked
+ ? null
+ : () => _showRenameDialog(context, session),
child: Text(
session.name,
style: TextStyle(
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
- color: isActive ? AppColors.accent : null,
+ color: isLocked
+ ? AppColors.darkTextTertiary
+ : isActive
+ ? AppColors.accent
+ : null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
- trailing: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- if (unread > 0)
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 6, vertical: 2),
- decoration: BoxDecoration(
- color: AppColors.unreadBadge,
- borderRadius:
- BorderRadius.circular(10),
+ trailing: isLocked
+ ? TextButton(
+ onPressed: () {
+ Navigator.pop(context);
+ onUpgrade?.call();
+ },
+ style: TextButton.styleFrom(
+ foregroundColor: AppColors.accent,
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8),
+ minimumSize: Size.zero,
+ tapTargetSize:
+ MaterialTapTargetSize.shrinkWrap,
),
- child: Text(
- '$unread',
- style: const TextStyle(
- color: Colors.white,
- fontSize: 11,
- fontWeight: FontWeight.bold,
+ child: const Text('Upgrade',
+ style: TextStyle(fontSize: 12)),
+ )
+ : Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (unread > 0)
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: AppColors.unreadBadge,
+ borderRadius:
+ BorderRadius.circular(10),
+ ),
+ child: Text(
+ '$unread',
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ const SizedBox(width: 4),
+ ReorderableDragStartListener(
+ index: index,
+ child: Icon(
+ Icons.drag_handle,
+ color: isDark
+ ? AppColors.darkTextTertiary
+ : Colors.grey.shade400,
+ size: 20,
+ ),
),
- ),
+ ],
),
- const SizedBox(width: 4),
- ReorderableDragStartListener(
- index: index,
- child: Icon(
- Icons.drag_handle,
- color: isDark
- ? AppColors.darkTextTertiary
- : Colors.grey.shade400,
- size: 20,
- ),
- ),
- ],
- ),
selected: isActive,
selectedTileColor: isDark
? Colors.white.withAlpha(10)
: Colors.blue.withAlpha(15),
- onTap: () {
- onSelect(session);
- Navigator.pop(context);
- },
+ onTap: isLocked
+ ? () {
+ Navigator.pop(context);
+ onUpgrade?.call();
+ }
+ : () {
+ onSelect(session);
+ Navigator.pop(context);
+ },
),
);
},
),
),
- // New session button
+ // New session button (or upgrade prompt)
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(12),
child: SizedBox(
width: double.infinity,
- child: ElevatedButton.icon(
- onPressed: onNewSession,
- icon: const Icon(Icons.add, size: 20),
- label: const Text('New Session'),
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.accent,
- foregroundColor: Colors.white,
- ),
- ),
+ child: !isPro &&
+ sessions.length >= kFreeTierMaxSessions
+ ? OutlinedButton.icon(
+ onPressed: () {
+ Navigator.pop(context);
+ onUpgrade?.call();
+ },
+ icon: const Icon(Icons.lock_outline, size: 18),
+ label: const Text('Upgrade for More Sessions'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: AppColors.accent,
+ side: const BorderSide(color: AppColors.accent),
+ ),
+ )
+ : ElevatedButton.icon(
+ onPressed: onNewSession,
+ icon: const Icon(Icons.add, size: 20),
+ label: const Text('New Session'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppColors.accent,
+ foregroundColor: Colors.white,
+ ),
+ ),
),
),
],
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index 96555f3..ba1a8cf 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -13,6 +13,7 @@
import file_selector_macos
import flutter_app_badger
import flutter_secure_storage_macos
+import in_app_purchase_storekit
import push
import record_macos
import share_plus
@@ -27,6 +28,7 @@
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterAppBadgerPlugin.register(with: registry.registrar(forPlugin: "FlutterAppBadgerPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
+ InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
PushPlugin.register(with: registry.registrar(forPlugin: "PushPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
diff --git a/pubspec.lock b/pubspec.lock
index f3d42d4..f4a00a3 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -512,6 +512,38 @@
url: "https://pub.dev"
source: hosted
version: "0.2.2"
+ in_app_purchase:
+ dependency: "direct main"
+ description:
+ name: in_app_purchase
+ sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.3"
+ in_app_purchase_android:
+ dependency: transitive
+ description:
+ name: in_app_purchase_android
+ sha256: "634bee4734b17fe55f370f0ac07a22431a9666e0f3a870c6d20350856e8bbf71"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.0+10"
+ in_app_purchase_platform_interface:
+ dependency: transitive
+ description:
+ name: in_app_purchase_platform_interface
+ sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ in_app_purchase_storekit:
+ dependency: transitive
+ description:
+ name: in_app_purchase_storekit
+ sha256: "1d512809edd9f12ff88fce4596a13a18134e2499013f4d6a8894b04699363c93"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.8+1"
intl:
dependency: "direct main"
description:
@@ -528,6 +560,14 @@
url: "https://pub.dev"
source: hosted
version: "0.6.7"
+ json_annotation:
+ dependency: transitive
+ description:
+ name: json_annotation
+ sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.11.0"
leak_tracker:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index c084091..c941a09 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -34,6 +34,7 @@
push: ^3.3.3
flutter_app_badger: ^1.5.0
connectivity_plus: ^7.1.0
+ in_app_purchase: ^3.2.3
dev_dependencies:
flutter_test:
--
Gitblit v1.3.1