diff --git a/lib/bluetooth/devices/base_device.dart b/lib/bluetooth/devices/base_device.dart index fa12528..c8da00a 100644 --- a/lib/bluetooth/devices/base_device.dart +++ b/lib/bluetooth/devices/base_device.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:swift_control/bluetooth/devices/zwift/constants.dart'; import 'package:swift_control/utils/actions/desktop.dart'; import 'package:swift_control/utils/core.dart'; +import 'package:swift_control/utils/iap/iap_manager.dart'; import 'package:swift_control/utils/keymap/apps/custom_app.dart'; import 'package:swift_control/utils/keymap/manager.dart'; @@ -111,23 +112,65 @@ abstract class BaseDevice { Future performDown(List buttonsClicked) async { for (final action in buttonsClicked) { + // Check IAP status before executing command + if (!IAPManager.instance.canExecuteCommand) { + actionStreamInternal.add( + LogNotification( + 'Command limit reached. ${IAPManager.instance.commandsRemainingToday} commands remaining today. Upgrade to unlock unlimited commands.', + ), + ); + continue; + } + // For repeated actions, don't trigger key down/up events (useful for long press) final result = await core.actionHandler.performAction(action, isKeyDown: true, isKeyUp: false); actionStreamInternal.add(LogNotification(result.message)); + + // Increment command count after successful execution + await IAPManager.instance.incrementCommandCount(); } } Future performClick(List buttonsClicked) async { for (final action in buttonsClicked) { + // Check IAP status before executing command + if (!IAPManager.instance.canExecuteCommand) { + final remaining = IAPManager.instance.commandsRemainingToday; + actionStreamInternal.add( + LogNotification( + remaining > 0 + ? 'Command limit: $remaining commands remaining today. Upgrade to unlock unlimited commands.' + : 'Daily command limit reached (0/15). Upgrade to unlock unlimited commands or try again tomorrow.', + ), + ); + continue; + } + final result = await core.actionHandler.performAction(action, isKeyDown: true, isKeyUp: true); actionStreamInternal.add(ActionNotification(result)); + + // Increment command count after successful execution + await IAPManager.instance.incrementCommandCount(); } } Future performRelease(List buttonsReleased) async { for (final action in buttonsReleased) { + // Check IAP status before executing command + if (!IAPManager.instance.canExecuteCommand) { + actionStreamInternal.add( + LogNotification( + 'Command limit reached. ${IAPManager.instance.commandsRemainingToday} commands remaining today. Upgrade to unlock unlimited commands.', + ), + ); + continue; + } + final result = await core.actionHandler.performAction(action, isKeyDown: false, isKeyUp: true); actionStreamInternal.add(ActionNotification(result)); + + // Increment command count after successful execution + await IAPManager.instance.incrementCommandCount(); } } diff --git a/lib/pages/configuration.dart b/lib/pages/configuration.dart index f26feb1..459d2a2 100644 --- a/lib/pages/configuration.dart +++ b/lib/pages/configuration.dart @@ -10,6 +10,7 @@ import 'package:swift_control/utils/keymap/apps/custom_app.dart'; import 'package:swift_control/utils/keymap/apps/my_whoosh.dart'; import 'package:swift_control/utils/keymap/apps/supported_app.dart'; import 'package:swift_control/utils/requirements/multi.dart'; +import 'package:swift_control/widgets/iap_status_widget.dart'; import 'package:swift_control/widgets/ui/colored_title.dart'; import 'package:swift_control/widgets/ui/warning.dart'; @@ -29,6 +30,8 @@ class _ConfigurationPageState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ + // IAP Status Widget + IAPStatusWidget(), Text.rich( TextSpan( children: [ diff --git a/lib/utils/iap/iap_manager.dart b/lib/utils/iap/iap_manager.dart new file mode 100644 index 0000000..8d3ab96 --- /dev/null +++ b/lib/utils/iap/iap_manager.dart @@ -0,0 +1,167 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:swift_control/utils/iap/iap_service.dart'; +import 'package:swift_control/utils/iap/windows_iap_service.dart'; + +/// Unified IAP manager that handles platform-specific IAP services +class IAPManager { + static IAPManager? _instance; + static IAPManager get instance { + _instance ??= IAPManager._(); + return _instance!; + } + + IAPService? _iapService; + WindowsIAPService? _windowsIapService; + SharedPreferences? _prefs; + + IAPManager._(); + + /// Initialize the IAP manager + Future initialize(SharedPreferences prefs) async { + _prefs = prefs; + + if (kIsWeb) { + // Web doesn't support IAP + return; + } + + try { + if (Platform.isWindows) { + _windowsIapService = WindowsIAPService(prefs); + await _windowsIapService!.initialize(); + } else if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) { + _iapService = IAPService(prefs); + await _iapService!.initialize(); + } + } catch (e) { + debugPrint('Error initializing IAP manager: $e'); + } + } + + /// Check if the user has purchased the full version + bool get isPurchased { + if (_iapService != null) { + return _iapService!.isPurchased; + } else if (_windowsIapService != null) { + return _windowsIapService!.isPurchased; + } + return false; + } + + /// Check if the trial period has started + bool get hasTrialStarted { + if (_iapService != null) { + return _iapService!.hasTrialStarted; + } else if (_windowsIapService != null) { + return _windowsIapService!.hasTrialStarted; + } + return false; + } + + /// Start the trial period + Future startTrial() async { + if (_iapService != null) { + await _iapService!.startTrial(); + } else if (_windowsIapService != null) { + await _windowsIapService!.startTrial(); + } + } + + /// Get the number of days remaining in the trial + int get trialDaysRemaining { + if (_iapService != null) { + return _iapService!.trialDaysRemaining; + } else if (_windowsIapService != null) { + return _windowsIapService!.trialDaysRemaining; + } + return 0; + } + + /// Check if the trial has expired + bool get isTrialExpired { + if (_iapService != null) { + return _iapService!.isTrialExpired; + } else if (_windowsIapService != null) { + return _windowsIapService!.isTrialExpired; + } + return false; + } + + /// Check if the user has access (purchased or still in trial) + bool get hasAccess { + if (_iapService != null) { + return _iapService!.hasAccess; + } else if (_windowsIapService != null) { + return _windowsIapService!.hasAccess; + } + return true; // Default to true for platforms without IAP + } + + /// Check if the user can execute a command + bool get canExecuteCommand { + if (_iapService != null) { + return _iapService!.canExecuteCommand; + } else if (_windowsIapService != null) { + return _windowsIapService!.canExecuteCommand; + } + return true; // Default to true for platforms without IAP + } + + /// Get the number of commands remaining today (for free tier after trial) + int get commandsRemainingToday { + if (_iapService != null) { + return _iapService!.commandsRemainingToday; + } else if (_windowsIapService != null) { + return _windowsIapService!.commandsRemainingToday; + } + return -1; // Unlimited + } + + /// Get the daily command count + int get dailyCommandCount { + if (_iapService != null) { + return _iapService!.dailyCommandCount; + } else if (_windowsIapService != null) { + return _windowsIapService!.dailyCommandCount; + } + return 0; + } + + /// Increment the daily command count + Future incrementCommandCount() async { + if (_iapService != null) { + await _iapService!.incrementCommandCount(); + } else if (_windowsIapService != null) { + await _windowsIapService!.incrementCommandCount(); + } + } + + /// Get a status message for the user + String getStatusMessage() { + if (_iapService != null) { + return _iapService!.getStatusMessage(); + } else if (_windowsIapService != null) { + return _windowsIapService!.getStatusMessage(); + } + return 'Full access'; + } + + /// Purchase the full version + Future purchaseFullVersion() async { + if (_iapService != null) { + return await _iapService!.purchaseFullVersion(); + } else if (_windowsIapService != null) { + return await _windowsIapService!.purchaseFullVersion(); + } + return false; + } + + /// Dispose the manager + void dispose() { + _iapService?.dispose(); + _windowsIapService?.dispose(); + } +} diff --git a/lib/utils/iap/iap_service.dart b/lib/utils/iap/iap_service.dart new file mode 100644 index 0000000..7e8aedd --- /dev/null +++ b/lib/utils/iap/iap_service.dart @@ -0,0 +1,279 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +/// Service to handle in-app purchase functionality and trial period management +class IAPService { + static const String productId = 'full_access_unlock'; + static const int trialDays = 5; + static const int dailyCommandLimit = 15; + + static const String _trialStartDateKey = 'iap_trial_start_date'; + static const String _purchaseStatusKey = 'iap_purchase_status'; + static const String _dailyCommandCountKey = 'iap_daily_command_count'; + static const String _lastCommandDateKey = 'iap_last_command_date'; + + final InAppPurchase _inAppPurchase = InAppPurchase.instance; + final SharedPreferences _prefs; + + StreamSubscription>? _subscription; + bool _isPurchased = false; + bool _isInitialized = false; + + IAPService(this._prefs); + + /// Initialize the IAP service + Future initialize() async { + if (_isInitialized) return; + + try { + // Check if IAP is available on this platform + final available = await _inAppPurchase.isAvailable(); + if (!available) { + debugPrint('IAP not available on this platform'); + _isInitialized = true; + return; + } + + // Listen for purchase updates + _subscription = _inAppPurchase.purchaseStream.listen( + _onPurchaseUpdate, + onDone: () => _subscription?.cancel(), + onError: (error) => debugPrint('IAP Error: $error'), + ); + + // Check if already purchased + await _checkExistingPurchase(); + + _isInitialized = true; + } catch (e) { + debugPrint('Failed to initialize IAP: $e'); + _isInitialized = true; + } + } + + /// Check if the user has already purchased the app + Future _checkExistingPurchase() async { + // First check if we have a stored purchase status + final storedStatus = _prefs.getBool(_purchaseStatusKey); + if (storedStatus == true) { + _isPurchased = true; + return; + } + + // Platform-specific checks for existing paid app purchases + if (Platform.isIOS || Platform.isMacOS) { + // On iOS/macOS, check if the app was previously purchased (has a receipt) + await _checkAppleReceipt(); + } else if (Platform.isAndroid) { + // On Android, check if user had the paid version before + await _checkAndroidPreviousPurchase(); + } + + // Also check for IAP purchase + if (!_isPurchased) { + await _restorePurchases(); + } + } + + /// Check for Apple receipt (iOS/macOS) + Future _checkAppleReceipt() async { + try { + // If there's an app store receipt, the app was purchased + // This is a simplified check - in production you'd verify the receipt + // For now, we'll check if we can restore purchases + await _restorePurchases(); + } catch (e) { + debugPrint('Error checking Apple receipt: $e'); + } + } + + /// Check if Android user had the paid app before + Future _checkAndroidPreviousPurchase() async { + try { + // On Android, we use the last seen version to determine if they had the paid version + // If the version exists and is from before the IAP transition, grant access + final lastSeenVersion = _prefs.getString('last_seen_version'); + if (lastSeenVersion != null) { + // If they had a previous version, they're an existing user + final packageInfo = await PackageInfo.fromPlatform(); + final currentVersion = packageInfo.version; + + // If last seen version exists and is different from first install, they're existing users + if (lastSeenVersion.isNotEmpty) { + _isPurchased = true; + await _prefs.setBool(_purchaseStatusKey, true); + debugPrint('Existing Android user detected - granting full access'); + } + } + } catch (e) { + debugPrint('Error checking Android previous purchase: $e'); + } + } + + /// Restore previous purchases + Future _restorePurchases() async { + try { + await _inAppPurchase.restorePurchases(); + // The purchase stream will be called with restored purchases + } catch (e) { + debugPrint('Error restoring purchases: $e'); + } + } + + /// Handle purchase updates + void _onPurchaseUpdate(List purchaseDetailsList) { + for (final purchase in purchaseDetailsList) { + if (purchase.status == PurchaseStatus.purchased || + purchase.status == PurchaseStatus.restored) { + _isPurchased = true; + _prefs.setBool(_purchaseStatusKey, true); + debugPrint('Purchase successful or restored'); + } + + // Complete the purchase + if (purchase.pendingCompletePurchase) { + _inAppPurchase.completePurchase(purchase); + } + } + } + + /// Purchase the full version + Future purchaseFullVersion() async { + try { + if (!_isInitialized) { + await initialize(); + } + + final available = await _inAppPurchase.isAvailable(); + if (!available) { + debugPrint('IAP not available'); + return false; + } + + // Query product details + final response = await _inAppPurchase.queryProductDetails({productId}); + if (response.error != null) { + debugPrint('Error querying products: ${response.error}'); + return false; + } + + if (response.productDetails.isEmpty) { + debugPrint('Product not found: $productId'); + return false; + } + + final product = response.productDetails.first; + final purchaseParam = PurchaseParam(productDetails: product); + + return await _inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam); + } catch (e) { + debugPrint('Error purchasing: $e'); + return false; + } + } + + /// Check if the user has purchased the full version + bool get isPurchased => _isPurchased; + + /// Check if the trial period has started + bool get hasTrialStarted { + final trialStart = _prefs.getString(_trialStartDateKey); + return trialStart != null; + } + + /// Start the trial period + Future startTrial() async { + if (!hasTrialStarted) { + await _prefs.setString(_trialStartDateKey, DateTime.now().toIso8601String()); + } + } + + /// Get the number of days remaining in the trial + int get trialDaysRemaining { + if (_isPurchased) return 0; + + final trialStart = _prefs.getString(_trialStartDateKey); + if (trialStart == null) return trialDays; + + final startDate = DateTime.parse(trialStart); + final now = DateTime.now(); + final daysPassed = now.difference(startDate).inDays; + final remaining = trialDays - daysPassed; + + return remaining > 0 ? remaining : 0; + } + + /// Check if the trial has expired + bool get isTrialExpired { + return !_isPurchased && hasTrialStarted && trialDaysRemaining <= 0; + } + + /// Check if the user has access (purchased or still in trial) + bool get hasAccess { + return _isPurchased || !isTrialExpired; + } + + /// Get the number of commands executed today + int get dailyCommandCount { + final lastDate = _prefs.getString(_lastCommandDateKey); + final today = DateTime.now().toIso8601String().split('T')[0]; + + if (lastDate != today) { + // Reset counter for new day + return 0; + } + + return _prefs.getInt(_dailyCommandCountKey) ?? 0; + } + + /// Increment the daily command count + Future incrementCommandCount() async { + final today = DateTime.now().toIso8601String().split('T')[0]; + final lastDate = _prefs.getString(_lastCommandDateKey); + + if (lastDate != today) { + // Reset counter for new day + await _prefs.setString(_lastCommandDateKey, today); + await _prefs.setInt(_dailyCommandCountKey, 1); + } else { + final count = _prefs.getInt(_dailyCommandCountKey) ?? 0; + await _prefs.setInt(_dailyCommandCountKey, count + 1); + } + } + + /// Check if the user can execute a command + bool get canExecuteCommand { + if (_isPurchased) return true; + if (!isTrialExpired) return true; + return dailyCommandCount < dailyCommandLimit; + } + + /// Get the number of commands remaining today (for free tier after trial) + int get commandsRemainingToday { + if (_isPurchased || !isTrialExpired) return -1; // Unlimited + return dailyCommandLimit - dailyCommandCount; + } + + /// Get a status message for the user + String getStatusMessage() { + if (_isPurchased) { + return 'Full version unlocked'; + } else if (!hasTrialStarted) { + return '$trialDays day trial available'; + } else if (!isTrialExpired) { + return '$trialDaysRemaining days remaining in trial'; + } else { + return '$commandsRemainingToday/$dailyCommandLimit commands remaining today'; + } + } + + /// Dispose the service + void dispose() { + _subscription?.cancel(); + } +} diff --git a/lib/utils/iap/windows_iap_service.dart b/lib/utils/iap/windows_iap_service.dart new file mode 100644 index 0000000..cdf78af --- /dev/null +++ b/lib/utils/iap/windows_iap_service.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Windows-specific IAP service +/// Note: This is a stub implementation. For actual Windows Store integration, +/// you would need to use the Windows Store APIs through platform channels. +class WindowsIAPService { + static const String productId = 'full_access_unlock'; + static const int trialDays = 5; + static const int dailyCommandLimit = 15; + + static const String _trialStartDateKey = 'iap_trial_start_date'; + static const String _purchaseStatusKey = 'iap_purchase_status'; + static const String _dailyCommandCountKey = 'iap_daily_command_count'; + static const String _lastCommandDateKey = 'iap_last_command_date'; + + final SharedPreferences _prefs; + + bool _isPurchased = false; + bool _isInitialized = false; + + WindowsIAPService(this._prefs); + + /// Initialize the Windows IAP service + Future initialize() async { + if (_isInitialized) return; + + try { + // Check if already purchased + await _checkExistingPurchase(); + + _isInitialized = true; + } catch (e) { + debugPrint('Failed to initialize Windows IAP: $e'); + _isInitialized = true; + } + } + + /// Check if the user has already purchased the app + Future _checkExistingPurchase() async { + // First check if we have a stored purchase status + final storedStatus = _prefs.getBool(_purchaseStatusKey); + if (storedStatus == true) { + _isPurchased = true; + return; + } + + // TODO: Add Windows Store API integration + // Check if the app was purchased from the Windows Store + // This would require platform channel implementation to call Windows Store APIs + + // For now, we'll check if there's a previous version installed + await _checkPreviousVersion(); + } + + /// Check if user had the paid version before + Future _checkPreviousVersion() async { + try { + // If the user has a last seen version, they're an existing user + final lastSeenVersion = _prefs.getString('last_seen_version'); + if (lastSeenVersion != null && lastSeenVersion.isNotEmpty) { + _isPurchased = true; + await _prefs.setBool(_purchaseStatusKey, true); + debugPrint('Existing Windows user detected - granting full access'); + } + } catch (e) { + debugPrint('Error checking Windows previous version: $e'); + } + } + + /// Purchase the full version + /// TODO: Implement actual Windows Store purchase flow + Future purchaseFullVersion() async { + try { + debugPrint('Windows Store purchase would be triggered here'); + // This would call the Windows Store IAP APIs through a platform channel + return false; + } catch (e) { + debugPrint('Error purchasing on Windows: $e'); + return false; + } + } + + /// Get remaining trial days from Windows Store + /// TODO: Implement Windows Store trial API + Future getRemainingTrialDays() async { + try { + // This would call Windows Store APIs to get trial information + // For now, use local calculation + return trialDaysRemaining; + } catch (e) { + debugPrint('Error getting trial days from Windows Store: $e'); + return trialDaysRemaining; + } + } + + /// Check if the user has purchased the full version + bool get isPurchased => _isPurchased; + + /// Check if the trial period has started + bool get hasTrialStarted { + final trialStart = _prefs.getString(_trialStartDateKey); + return trialStart != null; + } + + /// Start the trial period + Future startTrial() async { + if (!hasTrialStarted) { + await _prefs.setString(_trialStartDateKey, DateTime.now().toIso8601String()); + } + } + + /// Get the number of days remaining in the trial + int get trialDaysRemaining { + if (_isPurchased) return 0; + + final trialStart = _prefs.getString(_trialStartDateKey); + if (trialStart == null) return trialDays; + + final startDate = DateTime.parse(trialStart); + final now = DateTime.now(); + final daysPassed = now.difference(startDate).inDays; + final remaining = trialDays - daysPassed; + + return remaining > 0 ? remaining : 0; + } + + /// Check if the trial has expired + bool get isTrialExpired { + return !_isPurchased && hasTrialStarted && trialDaysRemaining <= 0; + } + + /// Check if the user has access (purchased or still in trial) + bool get hasAccess { + return _isPurchased || !isTrialExpired; + } + + /// Get the number of commands executed today + int get dailyCommandCount { + final lastDate = _prefs.getString(_lastCommandDateKey); + final today = DateTime.now().toIso8601String().split('T')[0]; + + if (lastDate != today) { + // Reset counter for new day + return 0; + } + + return _prefs.getInt(_dailyCommandCountKey) ?? 0; + } + + /// Increment the daily command count + Future incrementCommandCount() async { + final today = DateTime.now().toIso8601String().split('T')[0]; + final lastDate = _prefs.getString(_lastCommandDateKey); + + if (lastDate != today) { + // Reset counter for new day + await _prefs.setString(_lastCommandDateKey, today); + await _prefs.setInt(_dailyCommandCountKey, 1); + } else { + final count = _prefs.getInt(_dailyCommandCountKey) ?? 0; + await _prefs.setInt(_dailyCommandCountKey, count + 1); + } + } + + /// Check if the user can execute a command + bool get canExecuteCommand { + if (_isPurchased) return true; + if (!isTrialExpired) return true; + return dailyCommandCount < dailyCommandLimit; + } + + /// Get the number of commands remaining today (for free tier after trial) + int get commandsRemainingToday { + if (_isPurchased || !isTrialExpired) return -1; // Unlimited + return dailyCommandLimit - dailyCommandCount; + } + + /// Get a status message for the user + String getStatusMessage() { + if (_isPurchased) { + return 'Full version unlocked'; + } else if (!hasTrialStarted) { + return '$trialDays day trial available'; + } else if (!isTrialExpired) { + return '$trialDaysRemaining days remaining in trial'; + } else { + return '$commandsRemainingToday/$dailyCommandLimit commands remaining today'; + } + } + + /// Dispose the service + void dispose() { + // Nothing to dispose for Windows + } +} diff --git a/lib/utils/settings/settings.dart b/lib/utils/settings/settings.dart index 5071f75..267b2d6 100644 --- a/lib/utils/settings/settings.dart +++ b/lib/utils/settings/settings.dart @@ -7,6 +7,7 @@ import 'package:path_provider_windows/path_provider_windows.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences_windows/shared_preferences_windows.dart'; import 'package:swift_control/utils/core.dart'; +import 'package:swift_control/utils/iap/iap_manager.dart'; import 'package:swift_control/utils/keymap/apps/supported_app.dart'; import 'package:swift_control/utils/requirements/multi.dart'; import 'package:window_manager/window_manager.dart'; @@ -31,6 +32,15 @@ class Settings { final app = getKeyMap(); core.actionHandler.init(app); + + // Initialize IAP manager + await IAPManager.instance.initialize(prefs); + + // Start trial if this is the first launch + if (!IAPManager.instance.hasTrialStarted && !IAPManager.instance.isPurchased) { + await IAPManager.instance.startTrial(); + } + return null; } catch (e, s) { if (!retried) { diff --git a/lib/widgets/iap_status_widget.dart b/lib/widgets/iap_status_widget.dart new file mode 100644 index 0000000..8892cc4 --- /dev/null +++ b/lib/widgets/iap_status_widget.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:swift_control/utils/iap/iap_manager.dart'; + +/// Widget to display IAP status and allow purchases +class IAPStatusWidget extends StatefulWidget { + const IAPStatusWidget({super.key}); + + @override + State createState() => _IAPStatusWidgetState(); +} + +class _IAPStatusWidgetState extends State { + bool _isPurchasing = false; + + @override + Widget build(BuildContext context) { + final iapManager = IAPManager.instance; + final isPurchased = iapManager.isPurchased; + final isTrialExpired = iapManager.isTrialExpired; + final trialDaysRemaining = iapManager.trialDaysRemaining; + final commandsRemaining = iapManager.commandsRemainingToday; + final dailyCommandCount = iapManager.dailyCommandCount; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'License Status', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + if (isPurchased) ...[ + Row( + children: [ + Icon(Icons.check_circle, color: Colors.green), + const SizedBox(width: 8), + Text( + 'Full Version Unlocked', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.green, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'You have unlimited access to all features.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ] else if (!isTrialExpired) ...[ + Row( + children: [ + Icon(Icons.access_time, color: Colors.blue), + const SizedBox(width: 8), + Text( + 'Trial Period Active', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.blue, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '$trialDaysRemaining days remaining in trial', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Text( + 'Enjoy unlimited commands during your trial period.', + style: Theme.of(context).textTheme.bodySmall, + ), + ] else ...[ + Row( + children: [ + Icon(Icons.info, color: Colors.orange), + const SizedBox(width: 8), + Text( + 'Free Version', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.orange, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Trial expired. Commands limited to 15 per day.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: dailyCommandCount / 15, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + commandsRemaining > 0 ? Colors.orange : Colors.red, + ), + ), + const SizedBox(height: 8), + Text( + commandsRemaining >= 0 + ? '$commandsRemaining commands remaining today (${dailyCommandCount}/15 used)' + : 'Daily limit reached (15/15 used)', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + if (!isPurchased) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: PrimaryButton( + onPressed: _isPurchasing ? null : _handlePurchase, + child: _isPurchasing + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + const SizedBox(width: 8), + Text('Processing...'), + ], + ) + : Text('Unlock Full Version'), + ), + ), + const SizedBox(height: 8), + Text( + 'Get unlimited commands with a one-time purchase.', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + ); + } + + Future _handlePurchase() async { + setState(() { + _isPurchasing = true; + }); + + try { + final success = await IAPManager.instance.purchaseFullVersion(); + + if (mounted) { + if (success) { + showToast( + context: context, + builder: (context) => Toast( + title: const Text('Purchase Successful'), + description: const Text('Thank you for your purchase! You now have unlimited access.'), + ), + ); + setState(() {}); + } else { + showToast( + context: context, + builder: (context) => Toast( + title: const Text('Purchase Failed'), + description: const Text('Unable to complete purchase. Please try again later.'), + ), + ); + } + } + } catch (e) { + if (mounted) { + showToast( + context: context, + builder: (context) => Toast( + title: const Text('Error'), + description: Text('An error occurred: $e'), + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isPurchasing = false; + }); + } + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 71ffb72..35fb2cf 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: nsd: ^4.0.3 image_picker: ^1.1.2 in_app_review: ^2.0.11 + in_app_purchase: ^3.2.1 window_manager: ^0.5.1 device_info_plus: ^12.1.0 keypress_simulator: