mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Merge branch 'copilot/integrate-revenuecat-sdk'
This commit is contained in:
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
361
lib/utils/iap/revenuecat_service.dart
Normal file
361
lib/utils/iap/revenuecat_service.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
24
pubspec.lock
24
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user