Merge branch 'copilot/integrate-revenuecat-sdk'

This commit is contained in:
Jonas Bark
2025-12-26 19:44:12 +01:00
19 changed files with 624 additions and 72 deletions

View File

@@ -119,7 +119,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: macos
args: "-- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
args: "-- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}"
- name: Decode Keystore
if: inputs.build_android
@@ -133,7 +133,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: android
args: "--artifact=apk"
args: "--artifact=apk -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}"
- name: Build Web
if: inputs.build_web
@@ -169,7 +169,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: ios
args: "--export-options-plist ios/ExportOptions.plist -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
args: "--export-options-plist ios/ExportOptions.plist -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}"
- name: Prepare App Store authentication key
if: inputs.build_ios || inputs.build_mac

View File

@@ -5,10 +5,10 @@ import android.os.Handler
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
import org.flame_engine.gamepads_android.GamepadsCompatibleActivity
class MainActivity: FlutterActivity(), GamepadsCompatibleActivity {
class MainActivity: FlutterFragmentActivity(), GamepadsCompatibleActivity {
var keyListener: ((KeyEvent) -> Boolean)? = null
var motionListener: ((MotionEvent) -> Boolean)? = null

View File

@@ -34,8 +34,22 @@ PODS:
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- purchases_flutter (8.11.0):
- Flutter
- PurchasesHybridCommon (= 14.3.0)
- purchases_ui_flutter (8.11.0):
- Flutter
- PurchasesHybridCommonUI (= 14.3.0)
- PurchasesHybridCommon (14.3.0):
- RevenueCat (= 5.32.0)
- PurchasesHybridCommonUI (14.3.0):
- PurchasesHybridCommon (= 14.3.0)
- RevenueCatUI (= 5.32.0)
- restart_app (0.0.1):
- Flutter
- RevenueCat (5.32.0)
- RevenueCatUI (5.32.0):
- RevenueCat (= 5.32.0)
- sensors_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -66,6 +80,8 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- purchases_flutter (from `.symlinks/plugins/purchases_flutter/ios`)
- purchases_ui_flutter (from `.symlinks/plugins/purchases_ui_flutter/ios`)
- restart_app (from `.symlinks/plugins/restart_app/ios`)
- sensors_plus (from `.symlinks/plugins/sensors_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -73,6 +89,13 @@ DEPENDENCIES:
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- PurchasesHybridCommon
- PurchasesHybridCommonUI
- RevenueCat
- RevenueCatUI
EXTERNAL SOURCES:
bluetooth_low_energy_darwin:
:path: ".symlinks/plugins/bluetooth_low_energy_darwin/darwin"
@@ -106,6 +129,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
purchases_flutter:
:path: ".symlinks/plugins/purchases_flutter/ios"
purchases_ui_flutter:
:path: ".symlinks/plugins/purchases_ui_flutter/ios"
restart_app:
:path: ".symlinks/plugins/restart_app/ios"
sensors_plus:
@@ -136,7 +163,13 @@ SPEC CHECKSUMS:
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
purchases_flutter: c1245d908efb42739b626d03905302c003d26811
purchases_ui_flutter: 6d910d07f4bcadbfd7bf3ff356278943d76cd34f
PurchasesHybridCommon: 7f0944cc5411bdcd1ea5d69affa6a6f9aaf87b13
PurchasesHybridCommonUI: 8149f983d0d5fcc6d2536900c934d3dfb7cbed45
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
RevenueCat: 7e1d0768fb287c9983173c9b28e39ccbeeb828a9
RevenueCatUI: 61ddba6f94803f9b79c470ffe1e8b81d807c11ce
sensors_plus: 7229095999f30740798f0eeef5cd120357a8f4f2
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart' show LogLevel;
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/actions/desktop.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
@@ -228,7 +229,7 @@ abstract class BaseDevice {
_getCommandLimitMessage(),
buttonTitle: AppLocalizations.current.purchase,
onTap: () {
IAPManager.instance.purchaseFullVersion();
IAPManager.instance.purchaseFullVersion(navigatorKey.currentContext!);
},
),
);

View File

@@ -13,6 +13,7 @@
"accessories": "Zubehör",
"action": "Aktion",
"adjustControllerButtons": "Controller-Tasten anpassen",
"afterDate": "Nach dem {date}",
"allow": "Erlauben",
"allowAccessibilityService": "Barrierefreiheitsdienst zulassen",
"allowBluetoothConnections": "Bluetooth-Verbindungen zulassen",
@@ -31,6 +32,7 @@
}
},
"battery": "Batterie",
"beforeDate": "Vor dem {date}",
"bluetoothAdvertiseAccess": "Bluetooth-Zugriff",
"bluetoothTurnedOn": "Bluetooth ist eingeschaltet",
"browserNotSupported": "Dieser Browser unterstützt kein Web-Bluetooth und die Plattform wird nicht unterstützt :(",
@@ -338,6 +340,7 @@
"requirement": "Anforderung",
"reset": "Zurücksetzen",
"restart": "Neustart",
"restorePurchaseInfo": "Klicke auf den Knopf oben und anschließend auf „Kauf wiederherstellen“. Bei Problemen kontaktiere mich bitte direkt.",
"runAppOnPlatformRemotely": "{appName} auf {platform} laufen lassen und es von diesem Gerät aus fernsteuern via {preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {

View File

@@ -13,6 +13,7 @@
"accessories": "Accessories",
"action": "Action",
"adjustControllerButtons": "Adjust Controller Buttons",
"afterDate": "After {date}",
"allow": "Allow",
"allowAccessibilityService": "Allow Accessibility Service",
"allowBluetoothConnections": "Allow Bluetooth Connections",
@@ -31,6 +32,7 @@
}
},
"battery": "Battery",
"beforeDate": "Before {date}",
"bluetoothAdvertiseAccess": "Bluetooth Advertise access",
"bluetoothTurnedOn": "Bluetooth turned on",
"browserNotSupported": "This Browser does not support Web Bluetooth and platform is not supported :(",
@@ -338,6 +340,7 @@
"requirement": "Requirement",
"reset": "Reset",
"restart": "Restart",
"restorePurchaseInfo": "Click on the button above, then on \"Restore Purchase\". Please contact me directly if you have any issues.",
"runAppOnPlatformRemotely": "Run {appName} on {platform} and control it remotely from this device{preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {

View File

@@ -13,6 +13,7 @@
"accessories": "Accessoires",
"action": "Action",
"adjustControllerButtons": "Ajuster les boutons de la manette",
"afterDate": "Après {date}",
"allow": "Permettre",
"allowAccessibilityService": "Autoriser le service d'accessibilité",
"allowBluetoothConnections": "Autoriser les connexions Bluetooth",
@@ -31,6 +32,7 @@
}
},
"battery": "Batterie",
"beforeDate": "Avant {date}",
"bluetoothAdvertiseAccess": "Accès à la publicité Bluetooth",
"bluetoothTurnedOn": "Bluetooth activé",
"browserNotSupported": "Ce navigateur ne prend pas en charge Web Bluetooth et la plateforme n'est pas prise en charge :(",
@@ -338,6 +340,7 @@
"requirement": "Exigence",
"reset": "Réinitialiser",
"restart": "Redémarrage",
"restorePurchaseInfo": "Cliquez sur le bouton ci-dessus, puis sur « Restaurer lachat ». Veuillez me contacter directement en cas de problème.",
"runAppOnPlatformRemotely": "Exécutez {appName} sur {platform} et contrôlez-le à distance depuis cet appareil{preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {

View File

@@ -13,6 +13,7 @@
"accessories": "Akcesoria",
"action": "Działanie",
"adjustControllerButtons": "Dostosuj przyciski kontrolera",
"afterDate": "Po {date}",
"allow": "Zezwól",
"allowAccessibilityService": "Zezwól na usługę ułatwień dostępu",
"allowBluetoothConnections": "Zezwól na połączenia Bluetooth",
@@ -31,6 +32,7 @@
}
},
"battery": "Bateria",
"beforeDate": "Zanim {date}",
"bluetoothAdvertiseAccess": "Dostęp do reklamy Bluetooth",
"bluetoothTurnedOn": "Włączono Bluetooth",
"browserNotSupported": "Ta przeglądarka nie obsługuje technologii Web Bluetooth i platforma nie jest obsługiwana :(",
@@ -241,7 +243,7 @@
"myWhooshLinkInfo": "W razie napotkania błędów prosimy o sprawdzenie sekcji rozwiązywania problemów. Wkrótce pojawi się znacznie bardziej niezawodna metoda połączenia!",
"needHelpClickHelp": "Potrzebujesz pomocy? Kliknij",
"needHelpDontHesitate": "przycisk na górze i prosimy o kontakt.",
"newConnectionMethodAnnouncement": "{trainerApp} już wkrótce będziemy wspierać znacznie lepsze i bardziej niezawodne metody połączeń — bądźcie na bieżąco z aktualizacjami!",
"newConnectionMethodAnnouncement": "{trainerApp} już wkrótce będzie wspierać znacznie lepsze i bardziej niezawodne metody połączeń — bądź na bieżąco z aktualizacjami!",
"newCustomProfile": "Nowy profil niestandardowy",
"newProfileName": "Nowa nazwa profilu",
"newVersionAvailable": "Dostępna jest nowa wersja",
@@ -262,7 +264,7 @@
"noControllerConnected": "Brak połączenia",
"noControllerUseCompanionMode": "Nie masz kontrolera? Użyj Companion Mode.",
"noIgnoredDevices": "Brak ignorowanych urządzeń.",
"noTrainerSelected": "Nie wybrano aplikacji treningowej",
"noTrainerSelected": "Nie wybrano trenażera",
"notConnected": "Nie połączono",
"notificationDescription": "Dzięki temu aplikacja działa w tle i powiadamia Cię o każdej zmianie połączenia z Twoim urządzeniem.",
"ok": "OK",
@@ -301,7 +303,7 @@
}
},
"playPause": "Start/Pauza",
"pleaseSelectAConnectionMethodFirst": "Najpierw wybierz metodę połączenia w ustawieniach aplikacji treningowej.",
"pleaseSelectAConnectionMethodFirst": "Najpierw wybierz metodę połączenia w ustawieniach trenażera.",
"predefinedAction": "Predefiniowana akcja {appName}",
"@predefinedAction": {
"placeholders": {
@@ -338,6 +340,7 @@
"requirement": "Wymóg",
"reset": "Reset",
"restart": "Uruchom ponownie",
"restorePurchaseInfo": "Kliknij przycisk powyżej, a następnie „Przywróć zakup”. W razie jakichkolwiek problemów skontaktuj się ze mną bezpośrednio.",
"runAppOnPlatformRemotely": "Uruchom {appName} na {platform} i steruj nim zdalnie z tego urządzenia {preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {

View File

@@ -7,7 +7,6 @@ import 'package:bike_control/pages/device.dart';
import 'package:bike_control/pages/trainer.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/widgets/logviewer.dart';
import 'package:bike_control/widgets/menu.dart';
import 'package:bike_control/widgets/title.dart';
@@ -48,7 +47,7 @@ class Navigation extends StatefulWidget {
State<Navigation> createState() => _NavigationState();
}
class _NavigationState extends State<Navigation> with WidgetsBindingObserver {
class _NavigationState extends State<Navigation> {
bool _isMobile = false;
late BCPage _selectedPage;
@@ -63,7 +62,6 @@ class _NavigationState extends State<Navigation> with WidgetsBindingObserver {
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_selectedPage = widget.page;
core.connection.initialize();
@@ -85,12 +83,6 @@ class _NavigationState extends State<Navigation> with WidgetsBindingObserver {
});
}
@override
void dispose() {
super.dispose();
WidgetsBinding.instance.removeObserver(this);
}
@override
void didUpdateWidget(covariant Navigation oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -101,13 +93,6 @@ class _NavigationState extends State<Navigation> with WidgetsBindingObserver {
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
IAPManager.instance.restorePurchases();
}
}
void _updateTrainerConnectionStatus() async {
final isConnected = await core.logic.isTrainerConnected();
if (mounted) {

View File

@@ -3,7 +3,9 @@ import 'dart:io';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/iap/iap_service.dart';
import 'package:bike_control/utils/iap/revenuecat_service.dart';
import 'package:bike_control/utils/iap/windows_iap_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -17,6 +19,7 @@ class IAPManager {
static int dailyCommandLimit = 15;
IAPService? _iapService;
RevenueCatService? _revenueCatService;
WindowsIAPService? _windowsIapService;
ValueNotifier<bool> isPurchased = ValueNotifier<bool>(false);
@@ -33,9 +36,21 @@ class IAPManager {
try {
if (Platform.isWindows) {
// Keep Windows using the existing windows_iap implementation
_windowsIapService = WindowsIAPService(prefs);
await _windowsIapService!.initialize();
} else if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) {
// Use RevenueCat for supported platforms when API key is available
_revenueCatService = RevenueCatService(
prefs,
isPurchasedNotifier: isPurchased,
getDailyCommandLimit: () => dailyCommandLimit,
setDailyCommandLimit: (limit) => dailyCommandLimit = limit,
);
await _revenueCatService!.initialize();
} else {
// Fall back to legacy IAP service
debugPrint('Using legacy IAP service (no RevenueCat key)');
_iapService = IAPService(prefs);
await _iapService!.initialize();
}
@@ -46,7 +61,9 @@ class IAPManager {
/// Check if the trial period has started
bool get hasTrialStarted {
if (_iapService != null) {
if (_revenueCatService != null) {
return _revenueCatService!.hasTrialStarted;
} else if (_iapService != null) {
return _iapService!.hasTrialStarted;
} else if (_windowsIapService != null) {
return _windowsIapService!.hasTrialStarted;
@@ -56,14 +73,18 @@ class IAPManager {
/// Start the trial period
Future<void> startTrial() async {
if (_iapService != null) {
if (_revenueCatService != null) {
await _revenueCatService!.startTrial();
} else if (_iapService != null) {
await _iapService!.startTrial();
}
}
/// Get the number of days remaining in the trial
int get trialDaysRemaining {
if (_iapService != null) {
if (_revenueCatService != null) {
return _revenueCatService!.trialDaysRemaining;
} else if (_iapService != null) {
return _iapService!.trialDaysRemaining;
} else if (_windowsIapService != null) {
return _windowsIapService!.trialDaysRemaining;
@@ -73,7 +94,9 @@ class IAPManager {
/// Check if the trial has expired
bool get isTrialExpired {
if (_iapService != null) {
if (_revenueCatService != null) {
return _revenueCatService!.isTrialExpired;
} else if (_iapService != null) {
return _iapService!.isTrialExpired;
} else if (_windowsIapService != null) {
return _windowsIapService!.isTrialExpired;
@@ -84,9 +107,11 @@ class IAPManager {
/// Check if the user can execute a command
bool get canExecuteCommand {
// If IAP is not initialized or not available, allow commands
if (_iapService == null && _windowsIapService == null) return true;
if (_revenueCatService == null && _iapService == null && _windowsIapService == null) return true;
if (_iapService != null) {
if (_revenueCatService != null) {
return _revenueCatService!.canExecuteCommand;
} else if (_iapService != null) {
return _iapService!.canExecuteCommand;
} else if (_windowsIapService != null) {
return _windowsIapService!.canExecuteCommand;
@@ -96,7 +121,9 @@ class IAPManager {
/// Get the number of commands remaining today (for free tier after trial)
int get commandsRemainingToday {
if (_iapService != null) {
if (_revenueCatService != null) {
return _revenueCatService!.commandsRemainingToday;
} else if (_iapService != null) {
return _iapService!.commandsRemainingToday;
} else if (_windowsIapService != null) {
return _windowsIapService!.commandsRemainingToday;
@@ -106,7 +133,9 @@ class IAPManager {
/// Get the daily command count
int get dailyCommandCount {
if (_iapService != null) {
if (_revenueCatService != null) {
return _revenueCatService!.dailyCommandCount;
} else if (_iapService != null) {
return _iapService!.dailyCommandCount;
} else if (_windowsIapService != null) {
return _windowsIapService!.dailyCommandCount;
@@ -116,7 +145,9 @@ class IAPManager {
/// Increment the daily command count
Future<void> incrementCommandCount() async {
if (_iapService != null) {
if (_revenueCatService != null) {
await _revenueCatService!.incrementCommandCount();
} else if (_iapService != null) {
await _iapService!.incrementCommandCount();
} else if (_windowsIapService != null) {
await _windowsIapService!.incrementCommandCount();
@@ -129,7 +160,7 @@ class IAPManager {
if (IAPManager.instance.isPurchased.value) {
return AppLocalizations.current.fullVersion;
} else if (!hasTrialStarted) {
return '${_iapService?.trialDaysRemaining ?? _windowsIapService?.trialDaysRemaining} day trial available';
return '${_revenueCatService?.trialDaysRemaining ?? _iapService?.trialDaysRemaining ?? _windowsIapService?.trialDaysRemaining} day trial available';
} else if (!isTrialExpired) {
return AppLocalizations.current.trialDaysRemaining(trialDaysRemaining);
} else {
@@ -138,8 +169,10 @@ class IAPManager {
}
/// Purchase the full version
Future<void> purchaseFullVersion() async {
if (_iapService != null) {
Future<void> purchaseFullVersion(BuildContext context) async {
if (_revenueCatService != null) {
return await _revenueCatService!.purchaseFullVersion(context);
} else if (_iapService != null) {
return await _iapService!.purchaseFullVersion();
} else if (_windowsIapService != null) {
return await _windowsIapService!.purchaseFullVersion();
@@ -148,24 +181,36 @@ class IAPManager {
/// Restore previous purchases
Future<void> restorePurchases() async {
if (_iapService != null) {
if (_revenueCatService != null) {
await _revenueCatService!.restorePurchases();
} else if (_iapService != null) {
await _iapService!.restorePurchases();
}
// Windows doesn't have a separate restore mechanism in the stub
}
/// Check if RevenueCat is being used
bool get isUsingRevenueCat => _revenueCatService != null;
/// Dispose the manager
void dispose() {
_revenueCatService?.dispose();
_iapService?.dispose();
_windowsIapService?.dispose();
}
void reset(bool fullReset) {
Future<void> reset(bool fullReset) async {
isPurchased.value = false;
_windowsIapService?.reset();
_iapService?.reset(fullReset);
await _revenueCatService?.reset(fullReset);
await _iapService?.reset(fullReset);
}
Future<void> redeem() async {
await _iapService!.redeem();
Future<void> redeem(String purchaseId) async {
if (_revenueCatService != null) {
await _revenueCatService!.redeem(purchaseId);
} else if (_iapService != null) {
await _iapService!.redeem();
}
}
}

View File

@@ -145,10 +145,9 @@ class IAPService {
if (receiptContent != null) {
debugPrint('Existing Apple user detected - validating receipt $receiptContent');
var sharedSecret =
Platform.environment['VERIFYING_SHARED_SECRET'] ?? String.fromEnvironment("VERIFYING_SHARED_SECRET");
Platform.environment['VERIFYING_SHARED_SECRET'] ?? const String.fromEnvironment("VERIFYING_SHARED_SECRET");
if (sharedSecret.isEmpty) {
sharedSecret = 'ac978d8af9f64db19fdbe6fbc494de2a';
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Shared Secret is empty'));
}
core.connection.signalNotification(
@@ -423,13 +422,13 @@ class IAPService {
_subscription?.cancel();
}
void reset(bool fullReset) {
Future<void> reset(bool fullReset) async {
if (fullReset) {
_prefs.deleteAll();
await _prefs.deleteAll();
} else {
_prefs.delete(key: _purchaseStatusKey);
await _prefs.delete(key: _purchaseStatusKey);
_isInitialized = false;
initialize();
await initialize();
}
}

View File

@@ -0,0 +1,361 @@
import 'dart:async';
import 'dart:io';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart' as zp;
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/widgets/ui/loading_widget.dart';
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
/// RevenueCat-based IAP service for iOS, macOS, and Android
class RevenueCatService {
static const int trialDays = 5;
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';
// RevenueCat entitlement identifier
static const String fullVersionEntitlement = 'Full Version';
final FlutterSecureStorage _prefs;
final ValueNotifier<bool> isPurchasedNotifier;
final int Function() getDailyCommandLimit;
final void Function(int limit) setDailyCommandLimit;
bool _isInitialized = false;
String? _trialStartDate;
String? _lastCommandDate;
int? _dailyCommandCount;
StreamSubscription<CustomerInfo>? _customerInfoSubscription;
RevenueCatService(
this._prefs, {
required this.isPurchasedNotifier,
required this.getDailyCommandLimit,
required this.setDailyCommandLimit,
});
/// Initialize the RevenueCat service
Future<void> initialize() async {
if (_isInitialized) return;
try {
// Skip RevenueCat initialization on web or unsupported platforms
if (kIsWeb) {
debugPrint('RevenueCat not supported on web');
_isInitialized = true;
return;
}
// Get API key from environment variable
final String apiKey;
if (Platform.isAndroid) {
apiKey =
Platform.environment['REVENUECAT_API_KEY_ANDROID'] ??
const String.fromEnvironment('REVENUECAT_API_KEY_ANDROID', defaultValue: '');
} else if (Platform.isIOS || Platform.isMacOS) {
apiKey =
Platform.environment['REVENUECAT_API_KEY_IOS'] ??
const String.fromEnvironment('REVENUECAT_API_KEY_IOS', defaultValue: '');
} else {
apiKey = '';
}
if (apiKey.isEmpty) {
debugPrint('RevenueCat API key not found in environment');
core.connection.signalNotification(
LogNotification('RevenueCat API key not configured'),
);
isPurchasedNotifier.value = false;
_isInitialized = true;
return;
}
// Configure RevenueCat
final configuration = PurchasesConfiguration(apiKey);
// Enable debug logs in debug mode
if (kDebugMode) {
await Purchases.setLogLevel(LogLevel.debug);
}
await Purchases.configure(configuration);
debugPrint('RevenueCat initialized successfully');
core.connection.signalNotification(
LogNotification('RevenueCat initialized'),
);
// Listen for customer info updates
Purchases.addCustomerInfoUpdateListener((customerInfo) {
_handleCustomerInfoUpdate(customerInfo);
});
_trialStartDate = await _prefs.read(key: _trialStartDateKey);
core.connection.signalNotification(
LogNotification('Trial start date: $_trialStartDate => $trialDaysRemaining'),
);
_lastCommandDate = await _prefs.read(key: _lastCommandDateKey);
final commandCount = await _prefs.read(key: _dailyCommandCountKey) ?? '0';
_dailyCommandCount = int.tryParse(commandCount);
// Check existing purchase status
await _checkExistingPurchase();
_isInitialized = true;
if (!isTrialExpired && Platform.isAndroid) {
setDailyCommandLimit(80);
}
} catch (e, s) {
recordError(e, s, context: 'Initializing RevenueCat Service');
core.connection.signalNotification(
AlertNotification(
zp.LogLevel.LOGLEVEL_ERROR,
'There was an error initializing RevenueCat. Please check your configuration.',
),
);
debugPrint('Failed to initialize RevenueCat: $e');
isPurchasedNotifier.value = false;
_isInitialized = true;
}
}
/// Check if the user has an active entitlement
Future<void> _checkExistingPurchase() async {
try {
// Check current entitlement status from RevenueCat
final customerInfo = await Purchases.getCustomerInfo();
_handleCustomerInfoUpdate(customerInfo);
} catch (e, s) {
debugPrint('Error checking existing purchase: $e');
recordError(e, s, context: 'Checking existing purchase');
}
}
/// Handle customer info updates from RevenueCat
void _handleCustomerInfoUpdate(CustomerInfo customerInfo) {
final hasEntitlement = customerInfo.entitlements.active.containsKey(fullVersionEntitlement);
debugPrint('RevenueCat entitlement check: $hasEntitlement');
core.connection.signalNotification(
LogNotification('Full Version entitlement: $hasEntitlement'),
);
isPurchasedNotifier.value = hasEntitlement;
if (hasEntitlement) {
_prefs.write(key: _purchaseStatusKey, value: "true");
}
}
/// Present the RevenueCat paywall
Future<void> presentPaywall() async {
try {
if (!_isInitialized) {
await initialize();
}
final paywallResult = await RevenueCatUI.presentPaywall(displayCloseButton: true);
debugPrint('Paywall result: $paywallResult');
// The customer info listener will handle the purchase update
} catch (e, s) {
debugPrint('Error presenting paywall: $e');
recordError(e, s, context: 'Presenting paywall');
core.connection.signalNotification(
AlertNotification(
zp.LogLevel.LOGLEVEL_ERROR,
'There was an error displaying the paywall. Please try again.',
),
);
}
}
/// Restore previous purchases
Future<void> restorePurchases() async {
try {
final customerInfo = await Purchases.restorePurchases();
_handleCustomerInfoUpdate(customerInfo);
core.connection.signalNotification(
LogNotification('Purchases restored'),
);
} catch (e, s) {
core.connection.signalNotification(
AlertNotification(
zp.LogLevel.LOGLEVEL_ERROR,
'There was an error restoring purchases. Please try again.',
),
);
recordError(e, s, context: 'Restore Purchases');
debugPrint('Error restoring purchases: $e');
}
}
/// Purchase the full version (use paywall instead)
Future<void> purchaseFullVersion(BuildContext context) async {
// Direct the user to the paywall for a better experience
if (Platform.isMacOS) {
showDropdown(
context: context,
builder: (c) => DropdownMenu(
children: [
MenuButton(
child: LoadingWidget(
futureCallback: () async {
await restorePurchases();
closeOverlay(c);
},
renderChild: (isLoading, tap) => TextButton(
onPressed: tap,
child: isLoading ? SmallProgressIndicator() : const Text('Restore Purchase').small,
),
),
),
MenuButton(
child: LoadingWidget(
futureCallback: () async {
try {
final offerings = await Purchases.getOfferings();
final purchaseParams = PurchaseParams.package(offerings.current!.availablePackages.first);
PurchaseResult result = await Purchases.purchase(purchaseParams);
core.connection.signalNotification(
LogNotification('Purchase result: $result'),
);
closeOverlay(c);
} on PlatformException catch (e) {
var errorCode = PurchasesErrorHelper.getErrorCode(e);
if (errorCode != PurchasesErrorCode.purchaseCancelledError) {
buildToast(context, title: e.message);
}
closeOverlay(c);
}
},
renderChild: (isLoading, tap) => TextButton(
onPressed: tap,
child: isLoading ? SmallProgressIndicator() : const Text('Purchase'),
),
),
),
],
),
);
} else {
await presentPaywall();
}
}
/// Check if the trial period has started
bool get hasTrialStarted {
return _trialStartDate != null;
}
/// Start the trial period
Future<void> startTrial() async {
if (!hasTrialStarted) {
await _prefs.write(key: _trialStartDateKey, value: DateTime.now().toIso8601String());
}
}
/// Get the number of days remaining in the trial
int get trialDaysRemaining {
if (isPurchasedNotifier.value) 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;
}
/// Check if the trial has expired
bool get isTrialExpired {
return (!isPurchasedNotifier.value && hasTrialStarted && trialDaysRemaining <= 0);
}
/// Get the number of commands executed today
int get dailyCommandCount {
final lastDate = _lastCommandDate;
final today = DateTime.now().toIso8601String().split('T')[0];
if (lastDate != today) {
// Reset counter for new day
_lastCommandDate = today;
_dailyCommandCount = 0;
}
return _dailyCommandCount ?? 0;
}
/// Increment the daily command count
Future<void> incrementCommandCount() async {
final today = DateTime.now().toIso8601String().split('T')[0];
final lastDate = await _prefs.read(key: _lastCommandDateKey);
if (lastDate != today) {
// Reset counter for new day
_lastCommandDate = today;
_dailyCommandCount = 1;
await _prefs.write(key: _lastCommandDateKey, value: today);
await _prefs.write(key: _dailyCommandCountKey, value: '1');
} else {
final count = _dailyCommandCount ?? 0;
_dailyCommandCount = count + 1;
await _prefs.write(key: _dailyCommandCountKey, value: _dailyCommandCount.toString());
}
}
/// Check if the user can execute a command
bool get canExecuteCommand {
if (isPurchasedNotifier.value) return true;
if (!isTrialExpired && !Platform.isAndroid) return true;
return dailyCommandCount < getDailyCommandLimit();
}
/// Get the number of commands remaining today (for free tier after trial)
int get commandsRemainingToday {
if (isPurchasedNotifier.value || (!isTrialExpired && !Platform.isAndroid)) return -1; // Unlimited
final remaining = getDailyCommandLimit() - dailyCommandCount;
return remaining > 0 ? remaining : 0; // Never return negative
}
/// Dispose the service
void dispose() {
_customerInfoSubscription?.cancel();
}
Future<void> reset(bool fullReset) async {
if (fullReset) {
await _prefs.deleteAll();
} else {
await _prefs.delete(key: _purchaseStatusKey);
_isInitialized = false;
await initialize();
}
}
Future<void> redeem(String purchaseId) async {
await Purchases.setAttributes({"purchase_id": purchaseId});
await Purchases.syncPurchases();
isPurchasedNotifier.value = true;
await _prefs.write(key: _purchaseStatusKey, value: isPurchasedNotifier.value.toString());
}
}

View File

@@ -1,8 +1,8 @@
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:flutter/services.dart';
import 'package:flutter_md/flutter_md.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/i18n_extension.dart';
class ChangelogDialog extends StatelessWidget {
final Markdown entry;
@@ -26,8 +26,10 @@ class ChangelogDialog extends StatelessWidget {
],
),
content: Container(
constraints: BoxConstraints(minWidth: 460),
child: MarkdownWidget(markdown: latestVersion),
constraints: BoxConstraints(minWidth: 460, maxHeight: 500),
child: Scrollbar(
child: SingleChildScrollView(child: MarkdownWidget(markdown: latestVersion)),
),
),
actions: [
TextButton(

View File

@@ -9,6 +9,8 @@ import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
@@ -22,11 +24,14 @@ class IAPStatusWidget extends StatefulWidget {
}
final _normalDate = DateTime(2026, 1, 15, 0, 0, 0, 0, 0);
final _iapDate = DateTime(2025, 12, 21, 0, 0, 0, 0, 0);
enum AlreadyBoughtOption { fullPurchase, iap, no }
class _IAPStatusWidgetState extends State<IAPStatusWidget> {
bool _isPurchasing = false;
bool _isSmall = false;
bool? _alreadyBoughtQuestion = null;
AlreadyBoughtOption? _alreadyBoughtQuestion;
final _purchaseIdField = const TextFieldKey(#purchaseId);
@@ -68,7 +73,7 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
}
: () {
if (Platform.isAndroid) {
if (_alreadyBoughtQuestion == false) {
if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) {
_handlePurchase();
}
} else {
@@ -195,12 +200,44 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
Text(AppLocalizations.of(context).alreadyBoughtTheAppPreviously).small,
Row(
children: [
OutlineButton(
child: Text(AppLocalizations.of(context).yes),
onPressed: () {
setState(() {
_alreadyBoughtQuestion = true;
});
Builder(
builder: (context) {
return OutlineButton(
child: Text(AppLocalizations.of(context).yes),
onPressed: () {
showDropdown(
context: context,
builder: (c) => DropdownMenu(
children: [
MenuButton(
child: Text(
AppLocalizations.of(
context,
).beforeDate(DateFormat.yMMMd().format(_iapDate)),
),
onPressed: (c) {
setState(() {
_alreadyBoughtQuestion = AlreadyBoughtOption.fullPurchase;
});
},
),
MenuButton(
child: Text(
AppLocalizations.of(
context,
).afterDate(DateFormat.yMMMd().format(_iapDate)),
),
onPressed: (c) {
setState(() {
_alreadyBoughtQuestion = AlreadyBoughtOption.iap;
});
},
),
],
),
);
},
);
},
),
const SizedBox(width: 8),
@@ -208,19 +245,19 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
child: Text(AppLocalizations.of(context).no),
onPressed: () {
setState(() {
_alreadyBoughtQuestion = false;
_alreadyBoughtQuestion = AlreadyBoughtOption.no;
});
},
),
],
),
] else if (_alreadyBoughtQuestion == true) ...[
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.fullPurchase) ...[
Text(
AppLocalizations.of(context).alreadyBoughtTheApp,
).small,
Form(
onSubmit: (context, values) async {
String purchaseId = _purchaseIdField[values]!;
String purchaseId = _purchaseIdField[values]!.trim();
setState(() {
_isLoading = true;
});
@@ -231,7 +268,7 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
supabaseUrl: 'https://pikrcyynovdvogrldfnw.supabase.co',
);
if (redeemed) {
await IAPManager.instance.redeem();
await IAPManager.instance.redeem(purchaseId);
buildToast(context, title: 'Success', subtitle: 'Purchase redeemed successfully!');
setState(() {
_isLoading = false;
@@ -252,9 +289,10 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
actions: [
OutlineButton(
child: Text(context.i18n.getSupport),
onPressed: () {
onPressed: () async {
final appUserId = await Purchases.appUserID;
launchUrlString(
'mailto:jonas@bikecontrol.app?subject=Bike%20Control%20Purchase%20Redemption%20Help',
'mailto:jonas@bikecontrol.app?subject=Bike%20Control%20Purchase%20Redemption%20Help%20for%20$appUserId',
);
},
),
@@ -305,7 +343,7 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
],
),
),
] else if (_alreadyBoughtQuestion == false) ...[
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.no) ...[
PrimaryButton(
onPressed: _isPurchasing ? null : _handlePurchase,
leading: Icon(Icons.star),
@@ -322,6 +360,34 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
: Text(AppLocalizations.of(context).unlockFullVersion),
),
Text(AppLocalizations.of(context).fullVersionDescription).xSmall,
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) ...[
PrimaryButton(
onPressed: _isPurchasing ? null : _handlePurchase,
leading: Icon(Icons.star),
child: _isPurchasing
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SmallProgressIndicator(),
const SizedBox(width: 8),
Text('Processing...'),
],
)
: Text(AppLocalizations.of(context).unlockFullVersion),
),
Text(
AppLocalizations.of(context).restorePurchaseInfo,
).xSmall,
OutlineButton(
child: Text(context.i18n.getSupport),
onPressed: () async {
final appUserId = await Purchases.appUserID;
launchUrlString(
'mailto:jonas@bikecontrol.app?subject=Bike%20Control%20Purchase%20Redemption%20Help%20for%20$appUserId',
);
},
),
],
],
),
@@ -366,7 +432,8 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
});
try {
await IAPManager.instance.purchaseFullVersion();
// Use RevenueCat paywall if available, otherwise fall back to legacy
await IAPManager.instance.purchaseFullVersion(context);
} catch (e) {
if (mounted) {
buildToast(
@@ -389,9 +456,9 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
required String supabaseAnonKey,
required String purchaseId,
}) async {
final uri = Uri.parse(
'$supabaseUrl/functions/v1/redeem-purchase',
);
final uri = Uri.parse('$supabaseUrl/functions/v1/redeem-purchase');
final appUserId = await Purchases.appUserID;
final response = await http.post(
uri,
@@ -401,6 +468,7 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
},
body: jsonEncode({
'purchaseId': purchaseId,
'userId': appUserId,
}),
);

View File

@@ -250,6 +250,7 @@ class BKMenuButton extends StatelessWidget {
MenuButton(
child: Text(context.i18n.continueAction),
onPressed: (c) {
IAPManager.instance.purchaseFullVersion(context);
core.connection.addDevices([
ZwiftClickV2(
BleDevice(

View File

@@ -19,6 +19,7 @@ import media_key_detector_macos
import nsd_macos
import package_info_plus
import path_provider_foundation
import purchases_flutter
import screen_retriever_macos
import shared_preferences_foundation
import universal_ble
@@ -41,6 +42,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))

View File

@@ -32,6 +32,12 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- purchases_flutter (9.10.2):
- FlutterMacOS
- PurchasesHybridCommon (= 17.25.0)
- PurchasesHybridCommon (17.25.0):
- RevenueCat (= 5.51.1)
- RevenueCat (5.51.1)
- screen_retriever_macos (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
@@ -63,6 +69,7 @@ DEPENDENCIES:
- nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- purchases_flutter (from `Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`)
@@ -70,6 +77,11 @@ DEPENDENCIES:
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
SPEC REPOS:
trunk:
- PurchasesHybridCommon
- RevenueCat
EXTERNAL SOURCES:
bluetooth_low_energy_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/bluetooth_low_energy_darwin/darwin
@@ -101,6 +113,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
purchases_flutter:
:path: Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos
screen_retriever_macos:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
shared_preferences_foundation:
@@ -130,6 +144,9 @@ SPEC CHECKSUMS:
nsd_macos: 1a38a38a33adbb396b4c6f303bc076073514cadc
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
purchases_flutter: 777401787df16312c7b8b53b2d7144d26b6da0f0
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6

View File

@@ -289,6 +289,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
equatable:
dependency: transitive
description:
name: equatable
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
expressions:
dependency: transitive
description:
@@ -1258,6 +1266,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.7"
purchases_flutter:
dependency: "direct main"
description:
name: purchases_flutter
sha256: "0a8ce3855dacb8c28e1e8de99cfe5592be2cb350c1210259d6fa4d9d0b152f89"
url: "https://pub.dev"
source: hosted
version: "9.10.2"
purchases_ui_flutter:
dependency: "direct main"
description:
name: purchases_ui_flutter
sha256: cb9eac034536fa2c62beb0c4a9e921a09e505ee7fbe304370a6764044ed9aef2
url: "https://pub.dev"
source: hosted
version: "9.10.2"
quiver:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: bike_control
description: "BikeControl - Control your virtual riding"
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 4.2.3+65
version: 4.2.4+66
environment:
sdk: ^3.9.0
@@ -36,6 +36,8 @@ dependencies:
path: ios_receipt
flutter_secure_storage: ^10.0.0
in_app_purchase: ^3.2.1
purchases_flutter: ^9.10.2
purchases_ui_flutter: ^9.10.2
windows_iap:
path: windows_iap
window_manager: ^0.5.1