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