diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5032317..dff44a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/android/app/src/main/kotlin/de/jonasbark/swiftcontrol/MainActivity.kt b/android/app/src/main/kotlin/de/jonasbark/swiftcontrol/MainActivity.kt index 3771b88..3f7f29e 100644 --- a/android/app/src/main/kotlin/de/jonasbark/swiftcontrol/MainActivity.kt +++ b/android/app/src/main/kotlin/de/jonasbark/swiftcontrol/MainActivity.kt @@ -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 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 11a763f..473ff10 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/lib/bluetooth/devices/base_device.dart b/lib/bluetooth/devices/base_device.dart index 4b6cd7c..4de30c4 100644 --- a/lib/bluetooth/devices/base_device.dart +++ b/lib/bluetooth/devices/base_device.dart @@ -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!); }, ), ); diff --git a/lib/i10n/intl_de.arb b/lib/i10n/intl_de.arb index 28dabca..0f550af 100644 --- a/lib/i10n/intl_de.arb +++ b/lib/i10n/intl_de.arb @@ -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": { diff --git a/lib/i10n/intl_en.arb b/lib/i10n/intl_en.arb index 61ff922..d2df82b 100644 --- a/lib/i10n/intl_en.arb +++ b/lib/i10n/intl_en.arb @@ -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": { diff --git a/lib/i10n/intl_fr.arb b/lib/i10n/intl_fr.arb index d016893..d386d1b 100644 --- a/lib/i10n/intl_fr.arb +++ b/lib/i10n/intl_fr.arb @@ -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 l’achat ». 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": { diff --git a/lib/i10n/intl_pl.arb b/lib/i10n/intl_pl.arb index f258eff..216e5d1 100644 --- a/lib/i10n/intl_pl.arb +++ b/lib/i10n/intl_pl.arb @@ -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": { diff --git a/lib/pages/navigation.dart b/lib/pages/navigation.dart index 73c9052..bc080ec 100644 --- a/lib/pages/navigation.dart +++ b/lib/pages/navigation.dart @@ -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 createState() => _NavigationState(); } -class _NavigationState extends State with WidgetsBindingObserver { +class _NavigationState extends State { bool _isMobile = false; late BCPage _selectedPage; @@ -63,7 +62,6 @@ class _NavigationState extends State with WidgetsBindingObserver { void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); _selectedPage = widget.page; core.connection.initialize(); @@ -85,12 +83,6 @@ class _NavigationState extends State 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 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) { diff --git a/lib/utils/iap/iap_manager.dart b/lib/utils/iap/iap_manager.dart index 29c16fe..700ace1 100644 --- a/lib/utils/iap/iap_manager.dart +++ b/lib/utils/iap/iap_manager.dart @@ -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 isPurchased = ValueNotifier(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 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 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 purchaseFullVersion() async { - if (_iapService != null) { + Future 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 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 reset(bool fullReset) async { + isPurchased.value = false; _windowsIapService?.reset(); - _iapService?.reset(fullReset); + await _revenueCatService?.reset(fullReset); + await _iapService?.reset(fullReset); } - Future redeem() async { - await _iapService!.redeem(); + Future redeem(String purchaseId) async { + if (_revenueCatService != null) { + await _revenueCatService!.redeem(purchaseId); + } else if (_iapService != null) { + await _iapService!.redeem(); + } } } diff --git a/lib/utils/iap/iap_service.dart b/lib/utils/iap/iap_service.dart index 4760732..9d428cb 100644 --- a/lib/utils/iap/iap_service.dart +++ b/lib/utils/iap/iap_service.dart @@ -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 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(); } } diff --git a/lib/utils/iap/revenuecat_service.dart b/lib/utils/iap/revenuecat_service.dart new file mode 100644 index 0000000..2446ef9 --- /dev/null +++ b/lib/utils/iap/revenuecat_service.dart @@ -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 isPurchasedNotifier; + final int Function() getDailyCommandLimit; + final void Function(int limit) setDailyCommandLimit; + + bool _isInitialized = false; + String? _trialStartDate; + String? _lastCommandDate; + int? _dailyCommandCount; + StreamSubscription? _customerInfoSubscription; + + RevenueCatService( + this._prefs, { + required this.isPurchasedNotifier, + required this.getDailyCommandLimit, + required this.setDailyCommandLimit, + }); + + /// Initialize the RevenueCat service + Future 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 _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 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 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 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 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 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 reset(bool fullReset) async { + if (fullReset) { + await _prefs.deleteAll(); + } else { + await _prefs.delete(key: _purchaseStatusKey); + _isInitialized = false; + await initialize(); + } + } + + Future 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()); + } +} diff --git a/lib/widgets/changelog_dialog.dart b/lib/widgets/changelog_dialog.dart index c896440..4e99344 100644 --- a/lib/widgets/changelog_dialog.dart +++ b/lib/widgets/changelog_dialog.dart @@ -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( diff --git a/lib/widgets/iap_status_widget.dart b/lib/widgets/iap_status_widget.dart index da8ef7c..e2c460f 100644 --- a/lib/widgets/iap_status_widget.dart +++ b/lib/widgets/iap_status_widget.dart @@ -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 { bool _isPurchasing = false; bool _isSmall = false; - bool? _alreadyBoughtQuestion = null; + AlreadyBoughtOption? _alreadyBoughtQuestion; final _purchaseIdField = const TextFieldKey(#purchaseId); @@ -68,7 +73,7 @@ class _IAPStatusWidgetState extends State { } : () { if (Platform.isAndroid) { - if (_alreadyBoughtQuestion == false) { + if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) { _handlePurchase(); } } else { @@ -195,12 +200,44 @@ class _IAPStatusWidgetState extends State { 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 { 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 { 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 { 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 { ], ), ), - ] 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 { : 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 { }); 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 { 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 { }, body: jsonEncode({ 'purchaseId': purchaseId, + 'userId': appUserId, }), ); diff --git a/lib/widgets/menu.dart b/lib/widgets/menu.dart index be04043..31702e0 100644 --- a/lib/widgets/menu.dart +++ b/lib/widgets/menu.dart @@ -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( diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 485f3bc..0e3a971 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 81d972f..e87f6ac 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -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 diff --git a/pubspec.lock b/pubspec.lock index 03e7ef3..f50b557 100755 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index eed59e1..7bf3172 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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