mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
windows #2
This commit is contained in:
@@ -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)
|
||||
@@ -64,8 +64,6 @@ class IAPManager {
|
||||
Future<void> startTrial() async {
|
||||
if (_iapService != null) {
|
||||
await _iapService!.startTrial();
|
||||
} else if (_windowsIapService != null) {
|
||||
await _windowsIapService!.startTrial();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
6
windows_iap/lib/models/trial.dart
Normal file
6
windows_iap/lib/models/trial.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
class Trial {
|
||||
final bool isTrial;
|
||||
final int remainingDays;
|
||||
|
||||
Trial({required this.isTrial, required this.remainingDays});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user