Add IAP service implementation with trial and command limiting

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-12 07:37:15 +00:00
parent a0ebac41ea
commit a03d250bdb
8 changed files with 898 additions and 0 deletions

View File

@@ -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<void> performDown(List<ControllerButton> 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<void> performClick(List<ControllerButton> 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<void> performRelease(List<ControllerButton> 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();
}
}

View File

@@ -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<ConfigurationPage> {
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// IAP Status Widget
IAPStatusWidget(),
Text.rich(
TextSpan(
children: [

View File

@@ -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<void> 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<void> 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<void> 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<bool> 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();
}
}

View File

@@ -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<List<PurchaseDetails>>? _subscription;
bool _isPurchased = false;
bool _isInitialized = false;
IAPService(this._prefs);
/// Initialize the IAP service
Future<void> 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<void> _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<void> _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<void> _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<void> _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<PurchaseDetails> 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<bool> 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<void> 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<void> 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();
}
}

View File

@@ -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<void> 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<void> _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<void> _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<bool> 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<int> 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<void> 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<void> 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
}
}

View File

@@ -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) {

View File

@@ -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<IAPStatusWidget> createState() => _IAPStatusWidgetState();
}
class _IAPStatusWidgetState extends State<IAPStatusWidget> {
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<Color>(
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<void> _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;
});
}
}
}
}

View File

@@ -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: