diff --git a/IAP_IMPLEMENTATION.md b/IAP_IMPLEMENTATION.md deleted file mode 100644 index 0de7b62..0000000 --- a/IAP_IMPLEMENTATION.md +++ /dev/null @@ -1,172 +0,0 @@ -# In-App Purchase Implementation - -This document describes the in-app purchase (IAP) implementation for BikeControl, which transitions the app from paid to free with IAP. - -## Overview - -The app now offers: -- **5-day free trial** for new users with unlimited commands -- **15 commands per day** after trial expires (free tier) -- **One-time purchase** to unlock unlimited commands - -Existing users who purchased the paid version are automatically granted full access. - -## Platform-Specific Configuration - -### iOS and macOS (App Store) - -1. **Create In-App Purchase in App Store Connect:** - - Product Type: **Non-Consumable** - - Product ID: `full_access_unlock` - - Name: "Full Access Unlock" (or your preferred name) - - Add localizations and pricing - -2. **Receipt Verification:** - - The app uses `appStoreReceiptURL` to check if the app was previously purchased as a paid app - - IAP restoration is handled automatically by the `in_app_purchase` plugin - -3. **App Store Transition:** - - Submit app update with IAP support - - After approval, change app price to **Free** in App Store Connect - - Update app description to explain the free trial and IAP - -### Android (Google Play) - -1. **Create In-App Product in Google Play Console:** - - Product Type: **One-time** (Managed Product) - - Product ID: `full_access_unlock` - - Add name, description, and pricing - -2. **Existing User Detection:** - - Uses `getLastSeenVersion()` from shared preferences - - If a user has a `last_seen_version`, they're considered an existing user and granted full access - -3. **Google Play Transition:** - - Publish app update with IAP support - - After update is live, change app to **Free** in Google Play Console - - Update store listing to explain the trial and IAP - -### Windows (Microsoft Store) - -1. **Add Durable Add-on in Partner Center:** - - Type: **Durable** (one-time purchase) - - Product ID: `full_access_unlock` - - Add name, description, and pricing - -2. **Trial Configuration:** - - Currently implemented with local trial tracking (5 days) - - Can be enhanced to use Windows Store built-in trial system - -3. **Existing User Detection:** - - Currently uses `last_seen_version` to detect existing users - - **TODO:** Integrate with Windows Store APIs for proper purchase verification - - Requires platform channel implementation for Windows Store API calls - -4. **Windows Store Transition:** - - Submit app update with IAP support - - After approval, set app to **Free** in Partner Center - - Update store description - -**Note:** The Windows implementation is currently a stub that needs full Windows Store API integration via platform channels. The packages `windows_store` and `windows_iap_plugin` mentioned in requirements are not yet integrated. - -## Code Structure - -### IAP Service Layer -- `lib/utils/iap/iap_service.dart` - Main IAP service for iOS/macOS/Android -- `lib/utils/iap/windows_iap_service.dart` - Windows-specific service (stub) -- `lib/utils/iap/iap_manager.dart` - Unified manager that routes to platform-specific service - -### Integration Points -- `lib/utils/settings/settings.dart` - Initializes IAP on app start -- `lib/bluetooth/devices/base_device.dart` - Checks IAP status before executing commands -- `lib/widgets/iap_status_widget.dart` - UI widget showing status and purchase button -- `lib/pages/configuration.dart` - Displays IAP status widget - -## Trial and Command Limiting - -### Trial Period -- **Duration:** 5 days from first app launch -- **Access:** Unlimited commands during trial -- **Activation:** Automatically starts on first launch for new users - -### After Trial Expires -- **Free Tier:** 15 commands per day -- **Reset:** Command counter resets daily at midnight -- **Messages:** Users see clear messages about remaining commands - -### Purchased Users -- **Unlimited Commands:** No restrictions -- **Status Display:** "Full Version Unlocked" message - -## Existing User Migration - -The app automatically detects and grants full access to existing users: - -### Detection Logic -1. **iOS/macOS:** Checks for app receipt and restores previous purchases -2. **Android:** Checks for `last_seen_version` in shared preferences -3. **Windows:** Checks for `last_seen_version` (to be enhanced with Store API) - -### Recommendation -Before transitioning to free, ensure all existing users have updated to this version so their `last_seen_version` is recorded. - -## Testing - -### Test IAP on Each Platform - -**iOS/macOS:** -1. Use Sandbox testers in App Store Connect -2. Test purchase flow -3. Test restoration of purchases -4. Verify existing user detection - -**Android:** -1. Use test tracks (internal/closed testing) -2. Add test accounts in Google Play Console -3. Test purchase flow -4. Verify existing user detection - -**Windows:** -1. Test local trial functionality -2. Test existing user detection -3. TODO: Test Windows Store purchase flow once implemented - -### Test Trial and Command Limiting -1. Test new user experience (trial starts automatically) -2. Simulate trial expiration by modifying stored trial date -3. Test command counter (execute 15+ commands after trial) -4. Verify daily reset of command counter -5. Test purchase unlock - -## Store Submission Checklist - -- [ ] Create IAP products in all stores (iOS, macOS, Android, Windows) -- [ ] Test IAP functionality on all platforms -- [ ] Submit app update with IAP support (keep app paid) -- [ ] Wait for approval on all platforms -- [ ] Change app price to Free on all platforms -- [ ] Update store descriptions to explain: - - Free 5-day trial - - 15 commands/day after trial - - One-time purchase for unlimited access - - Existing users have automatic full access -- [ ] Monitor reviews and support requests during transition - -## Known Limitations - -1. **Windows Store Integration:** - - Currently uses local trial tracking instead of Windows Store trial API - - Purchase flow is stubbed out - needs platform channel implementation - - Existing user detection works via `last_seen_version` but should be enhanced with Store API - -2. **Receipt Verification:** - - iOS/macOS receipt verification is simplified - - Production apps should implement server-side receipt verification for security - -## Future Enhancements - -1. Implement full Windows Store API integration -2. Add server-side receipt verification for iOS/macOS -3. Add analytics for trial conversion rates -4. Add promotional codes support -5. Add family sharing support (iOS/macOS) diff --git a/lib/utils/iap/iap_manager.dart b/lib/utils/iap/iap_manager.dart index 83ad3af..6ec5448 100644 --- a/lib/utils/iap/iap_manager.dart +++ b/lib/utils/iap/iap_manager.dart @@ -64,8 +64,6 @@ class IAPManager { Future startTrial() async { if (_iapService != null) { await _iapService!.startTrial(); - } else if (_windowsIapService != null) { - await _windowsIapService!.startTrial(); } } diff --git a/lib/utils/iap/windows_iap_service.dart b/lib/utils/iap/windows_iap_service.dart index 5226631..fd94c9f 100644 --- a/lib/utils/iap/windows_iap_service.dart +++ b/lib/utils/iap/windows_iap_service.dart @@ -1,20 +1,17 @@ import 'dart:async'; -import 'package:bike_control/utils/core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:version/version.dart'; import 'package:windows_iap/windows_iap.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 String productId = '9NP42GS03Z26'; 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'; @@ -24,7 +21,6 @@ class WindowsIAPService { bool _isPurchased = false; bool _isInitialized = false; - String? _trialStartDate; String? _lastCommandDate; int? _dailyCommandCount; @@ -40,7 +36,6 @@ class WindowsIAPService { // Check if already purchased await _checkExistingPurchase(); - _trialStartDate = await _prefs.read(key: _trialStartDateKey); _lastCommandDate = await _prefs.read(key: _lastCommandDateKey); _dailyCommandCount = int.tryParse(await _prefs.read(key: _dailyCommandCountKey) ?? '0'); _isInitialized = true; @@ -58,33 +53,13 @@ class WindowsIAPService { _isPurchased = true; return; } - _windowsIapPlugin. - // 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 { - // IMPORTANT: This assumes the app is currently paid and this update will be released - // while the app is still paid. Only users who downloaded the paid version will have - // a last_seen_version. After changing the app to free, new users won't have this set. - final lastSeenVersion = core.settings.getLastSeenVersion(); - if (lastSeenVersion != null && lastSeenVersion.isNotEmpty) { - Version lastVersion = Version.parse(lastSeenVersion); - // If they had a previous version, they're an existing paid user - _isPurchased = lastVersion < Version(4, 2, 0); - if (_isPurchased) { - await _prefs.write(key: _purchaseStatusKey, value: "true"); - } - debugPrint('Existing Android user detected - granting full access'); - } - } catch (e) { - debugPrint('Error checking Windows previous version: $e'); + final trial = await _windowsIapPlugin.getTrialStatusAndRemainingDays(); + trialDaysRemaining = trial.remainingDays; + if (!trial.isTrial && trial.remainingDays <= 0) { + _isPurchased = true; + await _prefs.write(key: _purchaseStatusKey, value: "true"); + } else { + _isPurchased = false; } } @@ -92,55 +67,22 @@ class WindowsIAPService { /// 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; + final status = await _windowsIapPlugin.makePurchase(productId); + return status == StorePurchaseStatus.succeeded || status == StorePurchaseStatus.alreadyPurchased; } 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 => _trialStartDate != null; - - /// Start the trial period - Future startTrial() async { - if (!hasTrialStarted) { - await _prefs.write(key: _trialStartDateKey, value: DateTime.now().toIso8601String()); - } - } + bool get hasTrialStarted => trialDaysRemaining > 0; /// Get the number of days remaining in the trial - int get trialDaysRemaining { - if (_isPurchased) return 0; - - final trialStart = _trialStartDate; - 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; - } + int trialDaysRemaining = 0; /// Check if the trial has expired bool get isTrialExpired { diff --git a/windows_iap/lib/models/trial.dart b/windows_iap/lib/models/trial.dart new file mode 100644 index 0000000..4d1b0f9 --- /dev/null +++ b/windows_iap/lib/models/trial.dart @@ -0,0 +1,6 @@ +class Trial { + final bool isTrial; + final int remainingDays; + + Trial({required this.isTrial, required this.remainingDays}); +} diff --git a/windows_iap/lib/windows_iap.dart b/windows_iap/lib/windows_iap.dart index 5fbe02f..6c713b5 100644 --- a/windows_iap/lib/windows_iap.dart +++ b/windows_iap/lib/windows_iap.dart @@ -4,11 +4,11 @@ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:windows_iap/models/product.dart'; +import 'package:windows_iap/models/trial.dart'; import 'models/store_license.dart'; import 'windows_iap_platform_interface.dart'; - enum StorePurchaseStatus { succeeded, alreadyPurchased, @@ -64,4 +64,8 @@ class WindowsIap { Future> getAddonLicenses() { return WindowsIapPlatform.instance.getAddonLicenses(); } + + Future getTrialStatusAndRemainingDays() { + return WindowsIapPlatform.instance.getTrialStatusAndRemainingDays(); + } } diff --git a/windows_iap/lib/windows_iap_method_channel.dart b/windows_iap/lib/windows_iap_method_channel.dart index cdf5e37..5f81957 100644 --- a/windows_iap/lib/windows_iap_method_channel.dart +++ b/windows_iap/lib/windows_iap_method_channel.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:windows_iap/models/trial.dart'; import 'models/product.dart'; import 'models/store_license.dart'; @@ -21,8 +22,7 @@ const _escapeMap = { }; /// A [RegExp] that matches whitespace characters that should be escaped. -var _escapeRegExp = RegExp( - '[\\x00-\\x07\\x0E-\\x1F${_escapeMap.keys.map(_getHexLiteral).join()}]'); +var _escapeRegExp = RegExp('[\\x00-\\x07\\x0E-\\x1F${_escapeMap.keys.map(_getHexLiteral).join()}]'); /// Returns [str] with all whitespace characters represented as their escape /// sequences. @@ -51,8 +51,7 @@ class MethodChannelWindowsIap extends WindowsIapPlatform { @override Future makePurchase(String storeId) async { - final result = await methodChannel - .invokeMethod('makePurchase', {'storeId': storeId}); + final result = await methodChannel.invokeMethod('makePurchase', {'storeId': storeId}); if (result == null) { return null; } @@ -73,12 +72,9 @@ class MethodChannelWindowsIap extends WindowsIapPlatform { @override Stream> productsStream() { - return const EventChannel('windows_iap_event_products') - .receiveBroadcastStream() - .map((event) { + return const EventChannel('windows_iap_event_products').receiveBroadcastStream().map((event) { if (event is String) { - return parseListNotNull( - json: jsonDecode(escape(event)), fromJson: Product.fromJson); + return parseListNotNull(json: jsonDecode(escape(event)), fromJson: Product.fromJson); } else { return []; } @@ -91,24 +87,30 @@ class MethodChannelWindowsIap extends WindowsIapPlatform { if (result == null) { return []; } - return parseListNotNull( - json: jsonDecode(escape(result)), fromJson: Product.fromJson); + return parseListNotNull(json: jsonDecode(escape(result)), fromJson: Product.fromJson); } @override Future checkPurchase({required String storeId}) async { - final result = await methodChannel - .invokeMethod('checkPurchase', {'storeId': storeId}); + final result = await methodChannel.invokeMethod('checkPurchase', {'storeId': storeId}); return result ?? false; } + @override + Future getTrialStatusAndRemainingDays() async { + final result = await methodChannel.invokeMethod('getTrialStatusAndRemainingDays'); + return Trial( + isTrial: result?['isTrial'] as bool? ?? false, + remainingDays: result?['remainingDays'] as int? ?? 0, + ); + } + @override Future> getAddonLicenses() async { final result = await methodChannel.invokeMethod('getAddonLicenses'); if (result == null) { return {}; } - return result.map((key, value) => - MapEntry(key.toString(), StoreLicense.fromJson(jsonDecode(value)))); + return result.map((key, value) => MapEntry(key.toString(), StoreLicense.fromJson(jsonDecode(value)))); } } diff --git a/windows_iap/lib/windows_iap_platform_interface.dart b/windows_iap/lib/windows_iap_platform_interface.dart index c97d81e..07ec197 100644 --- a/windows_iap/lib/windows_iap_platform_interface.dart +++ b/windows_iap/lib/windows_iap_platform_interface.dart @@ -1,6 +1,7 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:windows_iap/models/product.dart'; import 'package:windows_iap/models/store_license.dart'; +import 'package:windows_iap/models/trial.dart'; import 'windows_iap.dart'; import 'windows_iap_method_channel.dart'; @@ -38,6 +39,10 @@ abstract class WindowsIapPlatform extends PlatformInterface { throw UnimplementedError('checkPurchase() has not been implemented.'); } + Future getTrialStatusAndRemainingDays() { + throw UnimplementedError('checkPurchase() has not been implemented.'); + } + Future> getAddonLicenses() { throw UnimplementedError('getAddonLicenses() has not been implemented.'); } diff --git a/windows_iap/windows/windows_iap_plugin.cpp b/windows_iap/windows/windows_iap_plugin.cpp index 4693e63..6fe380e 100644 --- a/windows_iap/windows/windows_iap_plugin.cpp +++ b/windows_iap/windows/windows_iap_plugin.cpp @@ -226,8 +226,60 @@ namespace windows_iap { } } + /// +/// need to test in real app on store +/// + /// +/// need to test in real app on store +/// + foundation::IAsyncAction getTrialStatusAndRemainingDays( + std::unique_ptr> resultCallback) + { + auto license = co_await getStore().GetAppLicenseAsync(); - //////////////////////////////////////////////////////////////////////// END OF MY CODE ////////////////////////////////////////////////////////////// + flutter::EncodableMap result; + result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(false); + result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue(0); + + if (!license.IsActive()) { + resultCallback->Success(flutter::EncodableValue(result)); + co_return; + } + + if (license.IsTrial()) { + result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(true); + + auto expiration = license.TrialExpirationDate(); + + if (expiration.UniversalTime != 0) { + // Convert Windows DateTime (100ns ticks since 1601-01-01) to Unix time + int64_t ticks = expiration.UniversalTime; + time_t expirationUnix = + (ticks - 116444736000000000LL) / 10000000LL; + + time_t nowUnix; + time(&nowUnix); + + if (expirationUnix > nowUnix) { + double secondsLeft = difftime(expirationUnix, nowUnix); + int remainingDays = static_cast(secondsLeft / (60 * 60 * 24)); + + // Round up to include partial day + if (secondsLeft > remainingDays * 86400) { + remainingDays += 1; + } + + result[flutter::EncodableValue("remainingDays")] = + flutter::EncodableValue(remainingDays); + } + } + } + + resultCallback->Success(flutter::EncodableValue(result)); + } + + + //////////////////////////////////////////////////////////////////////// END OF MY CODE ////////////////////////////////////////////////////////////// // static void WindowsIapPlugin::RegisterWithRegistrar( @@ -272,6 +324,9 @@ namespace windows_iap { else if (method_call.method_name().compare("getAddonLicenses") == 0) { getAddonLicenses(std::move(result)); } + else if (method_call.method_name().compare("getTrialStatusAndRemainingDays") == 0) { + getTrialStatusAndRemainingDays(std::move(result)); + } else { result->NotImplemented(); }