This commit is contained in:
Jonas Bark
2025-12-14 20:15:33 +01:00
parent 0f4e46a758
commit 7149c98564
8 changed files with 101 additions and 261 deletions

View File

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

View File

@@ -64,8 +64,6 @@ class IAPManager {
Future<void> startTrial() async {
if (_iapService != null) {
await _iapService!.startTrial();
} else if (_windowsIapService != null) {
await _windowsIapService!.startTrial();
}
}

View File

@@ -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<void> _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<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;
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<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 => _trialStartDate != null;
/// Start the trial period
Future<void> 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 {

View File

@@ -0,0 +1,6 @@
class Trial {
final bool isTrial;
final int remainingDays;
Trial({required this.isTrial, required this.remainingDays});
}

View File

@@ -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<Map<String, StoreLicense>> getAddonLicenses() {
return WindowsIapPlatform.instance.getAddonLicenses();
}
Future<Trial> getTrialStatusAndRemainingDays() {
return WindowsIapPlatform.instance.getTrialStatusAndRemainingDays();
}
}

View File

@@ -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<StorePurchaseStatus?> makePurchase(String storeId) async {
final result = await methodChannel
.invokeMethod<int>('makePurchase', {'storeId': storeId});
final result = await methodChannel.invokeMethod<int>('makePurchase', {'storeId': storeId});
if (result == null) {
return null;
}
@@ -73,12 +72,9 @@ class MethodChannelWindowsIap extends WindowsIapPlatform {
@override
Stream<List<Product>> 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<bool> checkPurchase({required String storeId}) async {
final result = await methodChannel
.invokeMethod<bool>('checkPurchase', {'storeId': storeId});
final result = await methodChannel.invokeMethod<bool>('checkPurchase', {'storeId': storeId});
return result ?? false;
}
@override
Future<Trial> getTrialStatusAndRemainingDays() async {
final result = await methodChannel.invokeMethod<Map>('getTrialStatusAndRemainingDays');
return Trial(
isTrial: result?['isTrial'] as bool? ?? false,
remainingDays: result?['remainingDays'] as int? ?? 0,
);
}
@override
Future<Map<String, StoreLicense>> getAddonLicenses() async {
final result = await methodChannel.invokeMethod<Map>('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))));
}
}

View File

@@ -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<Trial> getTrialStatusAndRemainingDays() {
throw UnimplementedError('checkPurchase() has not been implemented.');
}
Future<Map<String, StoreLicense>> getAddonLicenses() {
throw UnimplementedError('getAddonLicenses() has not been implemented.');
}

View File

@@ -226,8 +226,60 @@ namespace windows_iap {
}
}
/// <summary>
/// need to test in real app on store
/// </summary>
/// <summary>
/// need to test in real app on store
/// </summary>
foundation::IAsyncAction getTrialStatusAndRemainingDays(
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> 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<int>(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();
}