mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Add IAP service implementation with trial and command limiting
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
167
lib/utils/iap/iap_manager.dart
Normal file
167
lib/utils/iap/iap_manager.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
279
lib/utils/iap/iap_service.dart
Normal file
279
lib/utils/iap/iap_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
199
lib/utils/iap/windows_iap_service.dart
Normal file
199
lib/utils/iap/windows_iap_service.dart
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
196
lib/widgets/iap_status_widget.dart
Normal file
196
lib/widgets/iap_status_widget.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user