mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6ee15e3ba | ||
|
|
51793847cf | ||
|
|
f308aa3847 | ||
|
|
695b994577 | ||
|
|
2301d04c61 | ||
|
|
e46ec5172c | ||
|
|
691e108c82 | ||
|
|
166146a8f8 | ||
|
|
2a42cfc80f | ||
|
|
aff1f20ebe | ||
|
|
804fed799d | ||
|
|
b6450ba47c | ||
|
|
14a4234583 | ||
|
|
72cd165992 | ||
|
|
239aec2083 | ||
|
|
a43ab60dcb | ||
|
|
484b7f74e6 | ||
|
|
8c1ddcb019 | ||
|
|
306a5badef | ||
|
|
e82003cd57 | ||
|
|
6a6aafe0a9 | ||
|
|
4bd440e167 | ||
|
|
80d198787f | ||
|
|
7f7bae477b | ||
|
|
2917814ecc | ||
|
|
e268a86f20 | ||
|
|
9a13831e91 | ||
|
|
9f6e57b9ff | ||
|
|
86cf7491c3 | ||
|
|
681ec710a1 | ||
|
|
dc40d61766 | ||
|
|
326ff4ce24 | ||
|
|
ca81642c5c | ||
|
|
32e4b9762b | ||
|
|
0c94852246 | ||
|
|
d2f67e402b | ||
|
|
145e55fe68 | ||
|
|
fd6358c233 | ||
|
|
1d047ce9ef | ||
|
|
a27a0b8872 | ||
|
|
0e98a9d500 | ||
|
|
728b2e0b53 | ||
|
|
220ffa49c6 | ||
|
|
74d1ec58de | ||
|
|
b2d19f7e70 | ||
|
|
7db8dfec62 | ||
|
|
b0c0767dc7 | ||
|
|
2e880869f3 | ||
|
|
1edf949a7a | ||
|
|
04c85668fe | ||
|
|
fcefd863cd | ||
|
|
64bac7e50f | ||
|
|
f94c1b1851 | ||
|
|
3a013db311 | ||
|
|
0fd141316e | ||
|
|
2ed4f389c9 | ||
|
|
9a1a25ed17 | ||
|
|
173fae7472 | ||
|
|
82c9df2214 | ||
|
|
a49b6b81ae | ||
|
|
dffa7003bc | ||
|
|
818ff4909a | ||
|
|
d1e054d5c5 | ||
|
|
7689da9acd | ||
|
|
0bf336643d | ||
|
|
70dc5d19e7 | ||
|
|
4da91b0fa3 | ||
|
|
cb219c57c4 | ||
|
|
0be5500d78 | ||
|
|
94dcccdce3 | ||
|
|
91b6ffdbe2 | ||
|
|
baa3a73984 | ||
|
|
dd5c231c47 | ||
|
|
3a0d4e1cbc | ||
|
|
631f031daa | ||
|
|
a487539e6a | ||
|
|
ad7454236a | ||
|
|
e182fea4d1 | ||
|
|
6615061658 | ||
|
|
5c1d423806 | ||
|
|
171f97645f | ||
|
|
9511c233a2 | ||
|
|
adfc2bd8cf | ||
|
|
2adba27dca | ||
|
|
b18c85fc8e | ||
|
|
2e79a43827 | ||
|
|
0fec34bb56 | ||
|
|
db3e133199 | ||
|
|
971cb91615 | ||
|
|
e5e04f3d59 | ||
|
|
fd9f7388e8 | ||
|
|
6ae2297246 | ||
|
|
c6fb2e68b5 | ||
|
|
c84c685a8f | ||
|
|
102f4a8818 | ||
|
|
661d72fa8c | ||
|
|
38df962e43 | ||
|
|
e02563733f | ||
|
|
c246d2d1fe | ||
|
|
8dbed9a8b5 |
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
|
||||
|
||||
6
.github/workflows/patch.yml
vendored
6
.github/workflows/patch.yml
vendored
@@ -91,21 +91,21 @@ jobs:
|
||||
with:
|
||||
platform: macos
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs --allow-native-diffs'
|
||||
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}'
|
||||
|
||||
- name: 🚀 Shorebird Patch Android
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: android
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs --allow-native-diffs'
|
||||
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}'
|
||||
|
||||
- name: 🚀 Shorebird Patch iOS
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: ios
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs --allow-native-diffs'
|
||||
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}'
|
||||
|
||||
# shorebird struggles with the app from GitHub
|
||||
- name: Build macOS
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,3 +1,13 @@
|
||||
### 4.3.0 (07-01-2026)
|
||||
|
||||
**Features**:
|
||||
- Onboarding for new users
|
||||
- support controlling music & volume for Windows, macOS and Android
|
||||
- App is now available in Italian (thanks to Connect_Thanks2613)
|
||||
|
||||
**Fixes**:
|
||||
- Vibration setting now available for Zwift Ride devices
|
||||
|
||||
### 4.2.0 (20-12-2025)
|
||||
|
||||
BikeControl now offers a free trial period of 5 days for all features, so you can test everything before deciding to purchase a license. Please contact the support if you experience any issues!
|
||||
@@ -6,7 +16,7 @@ BikeControl now offers a free trial period of 5 days for all features, so you ca
|
||||
- support for SRAM AXS/eTap
|
||||
- only single or double click is supported (no individual button mapping possible, yet)
|
||||
- use your phone/tablet for steering by attaching your device on your handlebar!
|
||||
- App is now available in Polish (thanks to Wandrocet)
|
||||
- App is now available in Polish (thanks to Wandrocek)
|
||||
|
||||
**Fixes**:
|
||||
- You will now be notified when a connection to your controller is lost
|
||||
|
||||
17
INSTRUCTIONS_LOCAL.md
Normal file
17
INSTRUCTIONS_LOCAL.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## What is the Local connection method?
|
||||
*
|
||||
The Local connection method works by directly controlling the target trainer app on the same device by simulating user input (taps, keyboard inputs). This method does not require any network connection or additional hardware, making it the simplest and most straightforward way to connect to the trainer app.
|
||||
|
||||
There are predefined keymaps (touch positions or keyboard shortcuts) for popular trainer apps, allowing users to easily set up and start using the Local connection method without needing to configure anything manually. You can configure these keymaps in the Configuration tab. Note though that supported keyboard keys depend on the trainer app.
|
||||
|
||||
## When to use the Local connection method?
|
||||
*
|
||||
The Local connection method is ideal for users who:
|
||||
- Are running the trainer app on the same device as the controller app (e.g., both apps on a smartphone or tablet).
|
||||
- Do not want to deal with network configurations or potential connectivity issues.
|
||||
|
||||
## Limitations of the Local connection method
|
||||
*
|
||||
While the Local connection method is easy to set up and use, it has some limitations:
|
||||
- It may not work well with trainer apps that have complex user interfaces or require precise timing.
|
||||
- It is limited to the device on which both the controller and trainer apps are running, meaning it cannot be used for remote control scenarios.
|
||||
@@ -1,11 +1,10 @@
|
||||
## Instructions for using the MyWhoosh "Link" connection method
|
||||
*
|
||||
1) launch MyWhoosh on the device of your choice
|
||||
2) launch MyWhoosh Link, check if the "Link" connection works
|
||||
3) **close MyWhoosh Link** - very important!
|
||||
4) open BikeControl, follow on screen instructions
|
||||
2) OPTIONAL: launch MyWhoosh Link, confirm the "Link" connection works at all. **close MyWhoosh Link** - very important!
|
||||
3) open BikeControl, follow the on-screen instructions
|
||||
|
||||
Once you've confirmed the connection in BikeControl you won't have to repeat step 2 and 3 again in the future. This is just to make sure the connection works in general.
|
||||
Once you've confirmed the connection in BikeControl you won't have to do step 2 in the future. This is just to make sure the connection works in general.
|
||||
|
||||
And here's a video with a few explanations:
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
|
||||
- Shimano Di2
|
||||
- Configure your levers to use D-Fly channels with Shimano E-Tube app
|
||||
- SRAM AXS/eTap
|
||||
- Configure your levers not to do any action in the "SRAM AXS" app
|
||||
- only single or double click is supported (no individual button mapping possible, yet)
|
||||
- Wahoo Kickr Bike Shift
|
||||
- Wahoo Kickr Bike Pro
|
||||
@@ -84,16 +85,16 @@ Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
## How does it work?
|
||||
The app connects to your Controller devices (such as Zwift ones) automatically. BikeControl uses different methods of connecting to the trainer app, depending on the trainer app and operating system:
|
||||
- Connect to the trainer app on the same device or on another device using Network
|
||||
- available on Android, iOS, macOS, Windows
|
||||
- available on Android, iOS, iPadOS, macOS, Windows
|
||||
- supported by e.g. MyWhoosh, Rouvy and Zwift
|
||||
- Connect to the trainer app on another device by simulating a Bluetooth device
|
||||
- available on Android, iOS, macOS, Windows
|
||||
- available on Android, iOS, iPadOS, macOS, Windows
|
||||
- supported by e.g. Rouvy and Zwift
|
||||
- Directly control the trainer app via Accessibility features (simulating touch and keyboard input)
|
||||
- available on Android, macOS, Windows
|
||||
- supported by all trainer apps
|
||||
- Connect to supported trainer app using the [OpenBikeControl](https://openbikecontrol.org) protocol
|
||||
- available on Android, iOS, macOS, Windows
|
||||
- Connect to the supported trainer app using the [OpenBikeControl](https://openbikecontrol.org) protocol
|
||||
- available on Android, iOS, iPadOS, macOS, Windows
|
||||
|
||||
## Donate
|
||||
Please consider donating to support the development of this app :)
|
||||
|
||||
@@ -1 +1 @@
|
||||
4.1.0
|
||||
4.2.2
|
||||
|
||||
@@ -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 (9.10.2):
|
||||
- Flutter
|
||||
- PurchasesHybridCommon (= 17.25.0)
|
||||
- purchases_ui_flutter (9.10.2):
|
||||
- Flutter
|
||||
- PurchasesHybridCommonUI (= 17.25.0)
|
||||
- PurchasesHybridCommon (17.25.0):
|
||||
- RevenueCat (= 5.51.1)
|
||||
- PurchasesHybridCommonUI (17.25.0):
|
||||
- PurchasesHybridCommon (= 17.25.0)
|
||||
- RevenueCatUI (= 5.51.1)
|
||||
- restart_app (0.0.1):
|
||||
- Flutter
|
||||
- RevenueCat (5.51.1)
|
||||
- RevenueCatUI (5.51.1):
|
||||
- RevenueCat (= 5.51.1)
|
||||
- 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: 7cbb87481a018c1bb8b4966b7d446a7c19d96d87
|
||||
purchases_ui_flutter: 1905cebf3f46e03aeefba0f6258000f3cdd32641
|
||||
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
|
||||
PurchasesHybridCommonUI: 3c1f78addfb3f470713548b4eac8c59254d0efe6
|
||||
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
|
||||
RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0
|
||||
RevenueCatUI: c6acb3648fa58ccb4f0f178556ed0278b796234f
|
||||
sensors_plus: 7229095999f30740798f0eeef5cd120357a8f4f2
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
|
||||
|
||||
@@ -38,6 +38,11 @@ class KeyPressSimulator {
|
||||
return _platform.simulateKeyPress(key: key, modifiers: modifiers, keyDown: false);
|
||||
}
|
||||
|
||||
/// Simulate media key press.
|
||||
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) {
|
||||
return _platform.simulateMediaKey(mediaKey);
|
||||
}
|
||||
|
||||
@Deprecated('Please use simulateKeyDown & simulateKeyUp methods.')
|
||||
Future<void> simulateCtrlCKeyPress() async {
|
||||
const key = PhysicalKeyboardKey.keyC;
|
||||
|
||||
@@ -22,6 +22,9 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
|
||||
case "simulateMouseClick":
|
||||
simulateMouseClick(call, result: result)
|
||||
break
|
||||
case "simulateMediaKey":
|
||||
simulateMediaKey(call, result: result)
|
||||
break
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
@@ -114,4 +117,62 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
|
||||
eventKeyPress!.flags = flags
|
||||
return eventKeyPress!
|
||||
}
|
||||
|
||||
public func simulateMediaKey(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args:[String: Any] = call.arguments as! [String: Any]
|
||||
let keyIdentifier: String = args["key"] as! String
|
||||
|
||||
// Map string identifier to macOS NX key codes
|
||||
var mediaKeyCode: Int32 = 0
|
||||
switch keyIdentifier {
|
||||
case "playPause":
|
||||
mediaKeyCode = NX_KEYTYPE_PLAY
|
||||
case "stop":
|
||||
// macOS doesn't have a dedicated stop key in its media control API.
|
||||
// Following macOS conventions, we map stop to play/pause which toggles playback.
|
||||
// This matches the behavior of the physical media keys on Mac keyboards.
|
||||
mediaKeyCode = NX_KEYTYPE_PLAY
|
||||
case "next":
|
||||
mediaKeyCode = NX_KEYTYPE_FAST
|
||||
case "previous":
|
||||
mediaKeyCode = NX_KEYTYPE_REWIND
|
||||
case "volumeUp":
|
||||
mediaKeyCode = NX_KEYTYPE_SOUND_UP
|
||||
case "volumeDown":
|
||||
mediaKeyCode = NX_KEYTYPE_SOUND_DOWN
|
||||
default:
|
||||
result(FlutterError(code: "UNSUPPORTED_KEY", message: "Unsupported media key identifier", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
// Create and post the media key event (key down)
|
||||
let eventDown = NSEvent.otherEvent(
|
||||
with: .systemDefined,
|
||||
location: NSPoint.zero,
|
||||
modifierFlags: NSEvent.ModifierFlags(rawValue: 0xa00),
|
||||
timestamp: 0,
|
||||
windowNumber: 0,
|
||||
context: nil,
|
||||
subtype: 8,
|
||||
data1: Int((mediaKeyCode << 16) | (0xa << 8)),
|
||||
data2: -1
|
||||
)
|
||||
eventDown?.cgEvent?.post(tap: .cghidEventTap)
|
||||
|
||||
// Create and post the media key event (key up)
|
||||
let eventUp = NSEvent.otherEvent(
|
||||
with: .systemDefined,
|
||||
location: NSPoint.zero,
|
||||
modifierFlags: NSEvent.ModifierFlags(rawValue: 0xb00),
|
||||
timestamp: 0,
|
||||
windowNumber: 0,
|
||||
context: nil,
|
||||
subtype: 8,
|
||||
data1: Int((mediaKeyCode << 16) | (0xb << 8)),
|
||||
data2: -1
|
||||
)
|
||||
eventUp?.cgEvent?.post(tap: .cghidEventTap)
|
||||
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,4 +61,27 @@ class MethodChannelKeyPressSimulator extends KeyPressSimulatorPlatform {
|
||||
};
|
||||
await methodChannel.invokeMethod('simulateMouseClick', arguments);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) async {
|
||||
// Map PhysicalKeyboardKey to string identifier since keyCode is null for media keys
|
||||
final keyMap = {
|
||||
PhysicalKeyboardKey.mediaPlayPause: 'playPause',
|
||||
PhysicalKeyboardKey.mediaStop: 'stop',
|
||||
PhysicalKeyboardKey.mediaTrackNext: 'next',
|
||||
PhysicalKeyboardKey.mediaTrackPrevious: 'previous',
|
||||
PhysicalKeyboardKey.audioVolumeUp: 'volumeUp',
|
||||
PhysicalKeyboardKey.audioVolumeDown: 'volumeDown',
|
||||
};
|
||||
|
||||
final keyIdentifier = keyMap[mediaKey];
|
||||
if (keyIdentifier == null) {
|
||||
throw UnsupportedError('Unsupported media key: $mediaKey');
|
||||
}
|
||||
|
||||
final Map<String, Object?> arguments = {
|
||||
'key': keyIdentifier,
|
||||
};
|
||||
await methodChannel.invokeMethod('simulateMediaKey', arguments);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ abstract class KeyPressSimulatorPlatform extends PlatformInterface {
|
||||
}
|
||||
|
||||
Future<void> simulateMouseClick(Offset position, {required bool keyDown}) {
|
||||
throw UnimplementedError('simulateKeyPress() has not been implemented.');
|
||||
throw UnimplementedError('simulateMouseClick() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) {
|
||||
throw UnimplementedError('simulateMediaKey() has not been implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <unordered_map>
|
||||
|
||||
using flutter::EncodableList;
|
||||
using flutter::EncodableMap;
|
||||
@@ -253,6 +254,49 @@ HWND FindTargetWindow(const std::string& processName, const std::string& windowT
|
||||
return data.foundWindow;
|
||||
}
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::SimulateMediaKey(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
|
||||
const EncodableMap& args = std::get<EncodableMap>(*method_call.arguments());
|
||||
std::string keyIdentifier = std::get<std::string>(args.at(EncodableValue("key")));
|
||||
|
||||
// Map string identifier to Windows virtual key codes
|
||||
static const std::unordered_map<std::string, UINT> keyMap = {
|
||||
{"playPause", VK_MEDIA_PLAY_PAUSE},
|
||||
{"stop", VK_MEDIA_STOP},
|
||||
{"next", VK_MEDIA_NEXT_TRACK},
|
||||
{"previous", VK_MEDIA_PREV_TRACK},
|
||||
{"volumeUp", VK_VOLUME_UP},
|
||||
{"volumeDown", VK_VOLUME_DOWN}
|
||||
};
|
||||
|
||||
auto it = keyMap.find(keyIdentifier);
|
||||
if (it == keyMap.end()) {
|
||||
result->Error("UNSUPPORTED_KEY", "Unsupported media key identifier");
|
||||
return;
|
||||
}
|
||||
UINT vkCode = it->second;
|
||||
|
||||
// Send key down event
|
||||
INPUT inputs[2] = {};
|
||||
inputs[0].type = INPUT_KEYBOARD;
|
||||
inputs[0].ki.wVk = static_cast<WORD>(vkCode);
|
||||
inputs[0].ki.dwFlags = 0; // Key down
|
||||
|
||||
// Send key up event
|
||||
inputs[1].type = INPUT_KEYBOARD;
|
||||
inputs[1].ki.wVk = static_cast<WORD>(vkCode);
|
||||
inputs[1].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
|
||||
UINT eventsSent = SendInput(2, inputs, sizeof(INPUT));
|
||||
if (eventsSent != 2) {
|
||||
result->Error("SEND_INPUT_FAILED", "Failed to send media key input events");
|
||||
return;
|
||||
}
|
||||
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
}
|
||||
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::HandleMethodCall(
|
||||
@@ -262,6 +306,8 @@ void KeypressSimulatorWindowsPlugin::HandleMethodCall(
|
||||
SimulateKeyPress(method_call, std::move(result));
|
||||
} else if (method_call.method_name().compare("simulateMouseClick") == 0) {
|
||||
SimulateMouseClick(method_call, std::move(result));
|
||||
} else if (method_call.method_name().compare("simulateMediaKey") == 0) {
|
||||
SimulateMediaKey(method_call, std::move(result));
|
||||
} else {
|
||||
result->NotImplemented();
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ class KeypressSimulatorWindowsPlugin : public flutter::Plugin {
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::SimulateMediaKey(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
|
||||
// Called when a method is called on this plugin's channel from Dart.
|
||||
void HandleMethodCall(
|
||||
|
||||
@@ -10,8 +10,8 @@ import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/requirements/android.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -50,6 +50,8 @@ class Connection {
|
||||
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
|
||||
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
|
||||
Stream<BaseDevice> get connectionStream => _connectionStreams.stream;
|
||||
final StreamController<BluetoothDevice> _rssiConnectionStreams = StreamController<BluetoothDevice>.broadcast();
|
||||
Stream<BluetoothDevice> get rssiConnectionStream => _rssiConnectionStreams.stream;
|
||||
|
||||
final _lastScanResult = <BleDevice>[];
|
||||
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
|
||||
@@ -72,7 +74,8 @@ class Connection {
|
||||
}
|
||||
});
|
||||
} else if (available == AvailabilityState.poweredOff) {
|
||||
reset();
|
||||
disconnectAll();
|
||||
stop();
|
||||
}
|
||||
};
|
||||
UniversalBle.onScanResult = (result) {
|
||||
@@ -82,7 +85,7 @@ class Connection {
|
||||
);
|
||||
if (existingDevice != null && existingDevice.rssi != result.rssi) {
|
||||
existingDevice.rssi = result.rssi;
|
||||
_connectionStreams.add(existingDevice); // Notify UI of update
|
||||
_rssiConnectionStreams.add(existingDevice); // Notify UI of update
|
||||
}
|
||||
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId && e.services.contentEquals(result.services))) {
|
||||
@@ -95,7 +98,9 @@ class Connection {
|
||||
final scanResult = BluetoothDevice.fromScanResult(result);
|
||||
|
||||
if (scanResult != null) {
|
||||
_actionStreams.add(LogNotification('Found new device: ${kIsWeb ? scanResult.name : scanResult.runtimeType}'));
|
||||
_actionStreams.add(
|
||||
LogNotification('Found new device: ${kIsWeb ? scanResult.toString() : scanResult.runtimeType}'),
|
||||
);
|
||||
addDevices([scanResult]);
|
||||
} else {
|
||||
final manufacturerData = result.manufacturerDataList;
|
||||
@@ -122,7 +127,7 @@ class Connection {
|
||||
// on web, log all characteristic changes for debugging
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
'Characteristic update for device ${device.name}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
|
||||
'Characteristic update for device ${device.toString()}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -131,7 +136,7 @@ class Connection {
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
"Error processing characteristic for device ${device.name} and char: $characteristicUuid: $e\n$backtrace",
|
||||
"Error processing characteristic for device ${device.toString()} and char: $characteristicUuid: $e\n$backtrace",
|
||||
),
|
||||
);
|
||||
if (kDebugMode) {
|
||||
@@ -211,14 +216,6 @@ class Connection {
|
||||
} else {
|
||||
isScanning.value = false;
|
||||
}
|
||||
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
_androidNotificationsSetup = true;
|
||||
// start foreground service only when app is in foreground
|
||||
NotificationRequirement.addPersistentNotification().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startMyWhooshServer() {
|
||||
@@ -274,11 +271,11 @@ class Connection {
|
||||
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue && !screenshotMode) {
|
||||
_handlingConnectionQueue = true;
|
||||
final device = _connectionQueue.removeAt(0);
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.name}'));
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.toString()}'));
|
||||
_connect(device)
|
||||
.then((_) {
|
||||
_handlingConnectionQueue = false;
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.name}'));
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.toString()}'));
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
_handleConnectionQueue();
|
||||
}
|
||||
@@ -288,11 +285,11 @@ class Connection {
|
||||
_handlingConnectionQueue = false;
|
||||
if (e is TimeoutException) {
|
||||
_actionStreams.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.name}: Timeout'),
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.toString()}: Timeout'),
|
||||
);
|
||||
} else {
|
||||
_actionStreams.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.name} - $e'),
|
||||
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.toString()} - $e'),
|
||||
);
|
||||
}
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
@@ -313,11 +310,11 @@ class Connection {
|
||||
_connectionStreams.add(device);
|
||||
core.flutterLocalNotificationsPlugin.show(
|
||||
1338,
|
||||
'${device.name} ${state ? AppLocalizations.current.connected.decapitalize() : AppLocalizations.current.disconnected.decapitalize()}',
|
||||
'${device.toString()} ${state ? AppLocalizations.current.connected.decapitalize() : AppLocalizations.current.disconnected.decapitalize()}',
|
||||
!state ? AppLocalizations.current.tryingToConnectAgain : null,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails('Connection', 'Connection Status'),
|
||||
iOS: DarwinNotificationDetails(presentAlert: true),
|
||||
iOS: DarwinNotificationDetails(presentAlert: true, presentSound: false),
|
||||
),
|
||||
);
|
||||
if (!device.isConnected) {
|
||||
@@ -332,9 +329,19 @@ class Connection {
|
||||
await device.connect();
|
||||
signalChange(device);
|
||||
|
||||
IAPManager.instance.setAttributes();
|
||||
|
||||
core.actionHandler.supportedApp?.keymap.addNewButtons(device.availableButtons);
|
||||
|
||||
_streamSubscriptions[device] = actionSubscription;
|
||||
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
_androidNotificationsSetup = true;
|
||||
// start foreground service only when app is in foreground
|
||||
NotificationRequirement.addPersistentNotification().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(LogNotification("$e\n$backtrace"));
|
||||
if (kDebugMode) {
|
||||
@@ -345,31 +352,6 @@ class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reset() async {
|
||||
_actionStreams.add(LogNotification('Disconnecting all devices'));
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
AndroidFlutterLocalNotificationsPlugin().stopForegroundService();
|
||||
_androidNotificationsSetup = false;
|
||||
}
|
||||
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
|
||||
if (isBtEnabled) {
|
||||
UniversalBle.stopScan();
|
||||
}
|
||||
isScanning.value = false;
|
||||
for (var device in bluetoothDevices) {
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
UniversalBle.disconnect(device.device.deviceId);
|
||||
signalChange(device);
|
||||
}
|
||||
_gamePadSearchTimer?.cancel();
|
||||
_lastScanResult.clear();
|
||||
hasDevices.value = false;
|
||||
devices.clear();
|
||||
}
|
||||
|
||||
void signalNotification(BaseNotification notification) {
|
||||
_actionStreams.add(notification);
|
||||
}
|
||||
@@ -386,8 +368,8 @@ class Connection {
|
||||
if (device is BluetoothDevice) {
|
||||
if (persistForget) {
|
||||
// Add device to ignored list when forgetting
|
||||
await core.settings.addIgnoredDevice(device.device.deviceId, device.name);
|
||||
_actionStreams.add(LogNotification('Device ignored: ${device.name}'));
|
||||
await core.settings.addIgnoredDevice(device.device.deviceId, device.toString());
|
||||
_actionStreams.add(LogNotification('Device ignored: ${device.toString()}'));
|
||||
}
|
||||
if (!forget) {
|
||||
// allow reconnection
|
||||
@@ -417,4 +399,29 @@ class Connection {
|
||||
|
||||
signalChange(device);
|
||||
}
|
||||
|
||||
Future<void> disconnectAll() async {
|
||||
_actionStreams.add(LogNotification('Disconnecting all devices'));
|
||||
for (var device in bluetoothDevices) {
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
device.disconnect();
|
||||
signalChange(device);
|
||||
devices.remove(device);
|
||||
}
|
||||
_gamePadSearchTimer?.cancel();
|
||||
_lastScanResult.clear();
|
||||
hasDevices.value = false;
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
|
||||
if (isBtEnabled) {
|
||||
UniversalBle.stopScan();
|
||||
}
|
||||
isScanning.value = false;
|
||||
_androidNotificationsSetup = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -35,7 +36,8 @@ abstract class BaseDevice {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || other is BaseDevice && runtimeType == other.runtimeType && name == other.name;
|
||||
identical(this, other) ||
|
||||
other is BaseDevice && runtimeType == other.runtimeType && toString() == other.toString();
|
||||
|
||||
@override
|
||||
int get hashCode => name.hashCode;
|
||||
@@ -228,7 +230,7 @@ abstract class BaseDevice {
|
||||
_getCommandLimitMessage(),
|
||||
buttonTitle: AppLocalizations.current.purchase,
|
||||
onTap: () {
|
||||
IAPManager.instance.purchaseFullVersion();
|
||||
IAPManager.instance.purchaseFullVersion(navigatorKey.currentContext!);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
@@ -21,6 +22,7 @@ import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/device_info.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:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
@@ -120,28 +122,33 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
?.payload;
|
||||
|
||||
if (data == null || data.isEmpty) {
|
||||
return null;
|
||||
} else {
|
||||
final type = ZwiftDeviceType.fromManufacturerData(data.first);
|
||||
device = switch (type) {
|
||||
ZwiftDeviceType.click => ZwiftClick(scanResult),
|
||||
ZwiftDeviceType.playRight => ZwiftPlay(scanResult),
|
||||
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult),
|
||||
ZwiftDeviceType.rideLeft => ZwiftRide(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
final type = ZwiftDeviceType.fromManufacturerData(data.first);
|
||||
return switch (type) {
|
||||
ZwiftDeviceType.click => ZwiftClick(scanResult),
|
||||
ZwiftDeviceType.playRight => ZwiftPlay(scanResult),
|
||||
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult),
|
||||
ZwiftDeviceType.rideLeft => ZwiftRide(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_
|
||||
when scanResult.name == 'Zwift Ride' &&
|
||||
type != ZwiftDeviceType.rideRight &&
|
||||
type != ZwiftDeviceType.rideLeft =>
|
||||
ZwiftRide(scanResult), // e.g. old firmware
|
||||
_ => null,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scanResult.name == 'Zwift Ride' && device == null) {
|
||||
// Fallback for Zwift Ride if nothing else matched => old firmware
|
||||
if (navigatorKey.currentContext?.mounted ?? false) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: 'Please update your Zwift Ride firmware.',
|
||||
duration: Duration(seconds: 6),
|
||||
);
|
||||
}
|
||||
device = ZwiftRide(scanResult);
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -152,11 +159,6 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
@override
|
||||
int get hashCode => scanResult.deviceId.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name + (firmwareVersion != null ? ' v$firmwareVersion' : '');
|
||||
}
|
||||
|
||||
BleDevice get device => scanResult;
|
||||
|
||||
@override
|
||||
@@ -230,7 +232,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
device.name?.screenshot ?? runtimeType.toString(),
|
||||
toString().screenshot ?? runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
@@ -302,7 +304,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
),
|
||||
if (firmwareVersion != null)
|
||||
DeviceInfo(
|
||||
title: context.i18n.signal,
|
||||
title: context.i18n.firmware,
|
||||
icon: this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion
|
||||
? Icons.warning
|
||||
: Icons.text_fields_sharp,
|
||||
@@ -316,15 +318,22 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
),
|
||||
|
||||
if (rssi != null)
|
||||
DeviceInfo(
|
||||
title: context.i18n.signal,
|
||||
icon: switch (rssi!) {
|
||||
>= -50 => Icons.signal_cellular_4_bar,
|
||||
>= -60 => Icons.signal_cellular_alt_2_bar,
|
||||
>= -70 => Icons.signal_cellular_alt_1_bar,
|
||||
_ => Icons.signal_cellular_alt,
|
||||
StreamBuilder(
|
||||
stream: core.connection.rssiConnectionStream
|
||||
.where((device) => device == this)
|
||||
.map((event) => event.rssi),
|
||||
builder: (context, rssiValue) {
|
||||
return DeviceInfo(
|
||||
title: context.i18n.signal,
|
||||
icon: switch (rssiValue.data ?? rssi!) {
|
||||
>= -50 => Icons.signal_cellular_4_bar,
|
||||
>= -60 => Icons.signal_cellular_alt_2_bar,
|
||||
>= -70 => Icons.signal_cellular_alt_1_bar,
|
||||
_ => Icons.signal_cellular_alt,
|
||||
},
|
||||
value: '$rssi dBm',
|
||||
);
|
||||
},
|
||||
value: '$rssi dBm',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
|
||||
class GamepadDevice extends BaseDevice {
|
||||
final String id;
|
||||
@@ -68,7 +68,7 @@ class GamepadDevice extends BaseDevice {
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
name.screenshot,
|
||||
toString().screenshot,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
|
||||
@@ -200,7 +200,7 @@ class GyroscopeSteering extends BaseDevice {
|
||||
spacing: 12,
|
||||
children: [
|
||||
Text(
|
||||
name.screenshot,
|
||||
toString().screenshot,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class HidDevice extends BaseDevice {
|
||||
HidDevice(super.name) : super(availableButtons: []);
|
||||
@@ -15,7 +15,7 @@ class HidDevice extends BaseDevice {
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: Text(name)),
|
||||
Expanded(child: Text(toString())),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/ble.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart' show AlertNotification, LogNotification;
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
@@ -13,8 +16,6 @@ import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../messages/notification.dart' show AlertNotification;
|
||||
|
||||
class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier<AppInfo?>(null);
|
||||
@@ -79,6 +80,12 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
|
||||
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
|
||||
await _peripheralManager.respondReadRequestWithValue(
|
||||
eventArgs.request,
|
||||
value: Uint8List.fromList([100]),
|
||||
);
|
||||
return;
|
||||
default:
|
||||
print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
}
|
||||
@@ -116,9 +123,9 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
|
||||
);
|
||||
print('Parsed App Info: $appInfo');
|
||||
core.connection.signalNotification(LogNotification('Parsed App Info: $appInfo'));
|
||||
} catch (e) {
|
||||
print('Error parsing App Info: $e');
|
||||
core.connection.signalNotification(LogNotification('Error parsing App Info ${bytesToHex(value)}: $e'));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -52,7 +52,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
throw 'Could not find network interface';
|
||||
}
|
||||
|
||||
_createTcpServer();
|
||||
await _createTcpServer();
|
||||
|
||||
if (kDebugMode) {
|
||||
enableLogging(LogTopic.calls);
|
||||
@@ -136,14 +136,18 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
final messageType = data[0];
|
||||
switch (messageType) {
|
||||
case OpenBikeProtocolParser.MSG_TYPE_APP_INFO:
|
||||
final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(data));
|
||||
isConnected.value = true;
|
||||
connectedApp.value = appInfo;
|
||||
try {
|
||||
final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(data));
|
||||
isConnected.value = true;
|
||||
connectedApp.value = appInfo;
|
||||
|
||||
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
|
||||
);
|
||||
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
|
||||
);
|
||||
} catch (e) {
|
||||
core.connection.signalNotification(LogNotification('Failed to parse app info: $e'));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unknown message type: $messageType');
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
|
||||
@@ -17,7 +18,7 @@ class ProtocolParseException implements Exception {
|
||||
ProtocolParseException(this.message, [this.raw]);
|
||||
|
||||
@override
|
||||
String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${raw!.length}' : ''}';
|
||||
String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${bytesToReadableHex(raw!)}' : ''}';
|
||||
}
|
||||
|
||||
class OpenBikeProtocolParser {
|
||||
@@ -28,20 +29,19 @@ class OpenBikeProtocolParser {
|
||||
0x02: ControllerButton('Shift Down', identifier: 0x02, action: InGameAction.shiftDown),
|
||||
0x03: ControllerButton('Gear Set', identifier: 0x03),
|
||||
// Navigation (0x10-0x1F)
|
||||
0x10: ControllerButton('Up/Steer Left', identifier: 0x10, action: InGameAction.steerLeft),
|
||||
0x11: ControllerButton('Down/Steer Right', identifier: 0x11, action: InGameAction.steerRight),
|
||||
0x10: ControllerButton('Up', identifier: 0x10, action: InGameAction.up),
|
||||
0x11: ControllerButton('Down', identifier: 0x11, action: InGameAction.down),
|
||||
0x12: ControllerButton('Left/Look Left', identifier: 0x12, action: InGameAction.navigateLeft),
|
||||
0x13: ControllerButton('Right/Look Right', identifier: 0x13, action: InGameAction.navigateRight),
|
||||
0x14: ControllerButton('Select/Confirm', identifier: 0x14, action: InGameAction.select),
|
||||
0x15: ControllerButton('Back/Cancel', identifier: 0x15, action: InGameAction.back),
|
||||
0x16: ControllerButton('Menu', identifier: 0x16),
|
||||
0x17: ControllerButton('Home', identifier: 0x17),
|
||||
0x18: ControllerButton('Steer Left', identifier: 0x18, action: InGameAction.steerLeft),
|
||||
0x19: ControllerButton('Steer Right', identifier: 0x19, action: InGameAction.steerRight),
|
||||
// Social/Emotes (0x20-0x2F)
|
||||
0x20: ControllerButton('Wave', identifier: 0x20, action: InGameAction.emote),
|
||||
0x21: ControllerButton('Thumbs Up', identifier: 0x21, action: InGameAction.emote),
|
||||
0x22: ControllerButton('Hammer Time', identifier: 0x22, action: InGameAction.emote),
|
||||
0x23: ControllerButton('Bell', identifier: 0x23),
|
||||
0x24: ControllerButton('Screenshot', identifier: 0x24),
|
||||
0x20: ControllerButton('Emote', identifier: 0x20, action: InGameAction.emote),
|
||||
0x21: ControllerButton('Push to Talk', identifier: 0x21),
|
||||
// Training Controls (0x30-0x3F)
|
||||
0x30: ControllerButton('ERG Up', identifier: 0x30, action: InGameAction.increaseResistance),
|
||||
0x31: ControllerButton('ERG Down', identifier: 0x31, action: InGameAction.decreaseResistance),
|
||||
|
||||
@@ -114,6 +114,7 @@ class SramAxs extends BluetoothDevice {
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
Text(
|
||||
"Don't forget to turn off the function of the button you want to use in the SRAM AXS app!\n"
|
||||
"Unfortunately, at the moment it's not possible to determine which physical button was pressed on your SRAM AXS device. Let us know if you have a contact at SRAM who can help :)\n\n"
|
||||
'So the app exposes two logical buttons:\n'
|
||||
'• SRAM Tap, assigned to Shift Up\n'
|
||||
|
||||
@@ -63,7 +63,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
|
||||
throw 'Could not find network interface';
|
||||
}
|
||||
|
||||
_createTcpServer();
|
||||
await _createTcpServer();
|
||||
|
||||
if (kDebugMode) {
|
||||
enableLogging(LogTopic.calls);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
@@ -9,6 +6,9 @@ import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftClickV2(super.scanResult)
|
||||
@@ -39,6 +39,11 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
@override
|
||||
bool get canVibrate => false;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "$name V2";
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setupHandshake() async {
|
||||
super.setupHandshake();
|
||||
@@ -65,51 +70,58 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
if (isConnected && _noLongerSendsEvents)
|
||||
if (isConnected)
|
||||
if (core.settings.getShowZwiftClickV2ReconnectWarning())
|
||||
Warning(
|
||||
Stack(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
Warning(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).clickV2Instructions,
|
||||
).xSmall,
|
||||
),
|
||||
IconButton.link(
|
||||
icon: Icon(Icons.close),
|
||||
Text(
|
||||
'Important Setup Information',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
),
|
||||
).small,
|
||||
Text(
|
||||
AppLocalizations.of(context).clickV2Instructions,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
),
|
||||
).xSmall,
|
||||
if (kDebugMode)
|
||||
GhostButton(
|
||||
onPressed: () {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
),
|
||||
|
||||
Button.secondary(
|
||||
onPressed: () {
|
||||
core.settings.setShowZwiftClickV2ReconnectWarning(false);
|
||||
setState(() {});
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
);
|
||||
},
|
||||
leading: const Icon(Icons.help_outline_outlined),
|
||||
child: Text(context.i18n.instructions),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
GhostButton(
|
||||
onPressed: () {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton.link(
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
),
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: const Icon(Icons.open_in_new),
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
),
|
||||
],
|
||||
onPressed: () {
|
||||
core.settings.setShowZwiftClickV2ReconnectWarning(false);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -123,11 +135,10 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
LinkButton(
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -5,10 +5,12 @@ import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/single_line_exception.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
abstract class ZwiftDevice extends BluetoothDevice {
|
||||
@@ -90,7 +92,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
if (kDebugMode) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
"Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -191,4 +193,24 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
if (canVibrate)
|
||||
Checkbox(
|
||||
trailing: Expanded(child: Text(context.i18n.enableVibrationFeedback)),
|
||||
state: core.settings.getVibrationEnabled() ? CheckboxState.checked : CheckboxState.unchecked,
|
||||
onChanged: (value) async {
|
||||
await core.settings.setVibrationEnabled(value == CheckboxState.checked);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class ZwiftPlay extends ZwiftDevice {
|
||||
ZwiftPlay(super.scanResult)
|
||||
@@ -62,23 +59,4 @@ class ZwiftPlay extends ZwiftDevice {
|
||||
|
||||
@override
|
||||
String get latestFirmwareVersion => '1.3.1';
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
Checkbox(
|
||||
trailing: Expanded(child: Text(context.i18n.enableVibrationFeedback)),
|
||||
state: core.settings.getVibrationEnabled() ? CheckboxState.checked : CheckboxState.unchecked,
|
||||
onChanged: (value) async {
|
||||
await core.settings.setVibrationEnabled(value == CheckboxState.checked);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -20,6 +21,8 @@
|
||||
"allowLocationForBluetooth": "Standortzugriff erlauben, damit Bluetooth-Scan funktioniert",
|
||||
"allowPersistentNotification": "Benachrichtigungen zulassen",
|
||||
"allowsRunningInBackground": "Ermöglicht es BikeControl, im Hintergrund weiterzulaufen.",
|
||||
"alreadyBoughtTheApp": "App bereits gekauft? Dann musst Du BikeControl nicht erneut kaufen. Aus technischen Gründen lässt sich leider nicht feststellen, ob die App früher bereits erworben wurde. \n\nGebe Deine Play Store-Kauf-ID (z.B. GPA.3356-1337-1338-1339) ein, um die Vollversion freizuschalten. Falls Du diese nicht finden kannst, kontaktiere mich bitte direkt.",
|
||||
"alreadyBoughtTheAppPreviously": "App zuvor bereits gekauft?",
|
||||
"appIdActions": "{appId} Aktionen",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
@@ -28,7 +31,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"asAFinalStepYoullChooseHowToConnectTo": "Im letzten Schritt wählen Sie die Verbindungsmethode aus für {trainerApp}",
|
||||
"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 :(",
|
||||
@@ -40,7 +45,7 @@
|
||||
"chooseBikeControlInConnectionScreen": "Wähle im Verbindungsbildschirm BikeControl aus.",
|
||||
"clickAButtonOnYourController": "Klicke eine Controller-Taste, um deren Aktion zu bearbeiten, oder tippe auf das Bearbeitungssymbol.",
|
||||
"clickV2EventInfo": "Dein Click V2 sendet möglicherweise keine Tastenereignisse mehr. Probier mal ein paar Tasten aus und schau, ob sie in BikeControl angezeigt werden.",
|
||||
"clickV2Instructions": "Damit dein Zwift Click V2 optimal funktioniert, solltest du vor jeder Trainings-Session in der Zwift-App verbinden.\nWenn du das nicht machst, funktioniert der Click V2 nach einer Minute nicht mehr.\n\n1. Öffne die Zwift-App.\n2. Melde dich an (kein Abonnement nötig) und öffne den Bildschirm für die Geräteverbindung.\n3. Verbinde deinen Trainer und dann den Zwift Click V2.\n4. Schließe die Zwift-App wieder und verbinde dich erneut in BikeControl.",
|
||||
"clickV2Instructions": "Damit dein Zwift Click V2 optimal funktioniert, solltest du das Gerät vor jeder Trainings-Session in der Zwift-App verbinden.\nAnsonsten funktioniert der Click V2 nach einer Minute nicht mehr.\n\n1. Öffne die Zwift-App.\n2. Melde dich an (kein Abonnement nötig) und öffne den Bildschirm für die Geräteverbindung.\n3. Verbinde deinen Trainer und dann den Zwift Click V2.\n4. Schließe die Zwift-App wieder und verbinde dich erneut in BikeControl.",
|
||||
"close": "Schließen",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} verbleibende Befehle heute",
|
||||
"configuration": "Konfiguration",
|
||||
@@ -73,6 +78,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllerConnectedClickButton": "Super! Dein Controller ist verbunden. Drücke eine Taste auf deinem Controller, um fortzufahren.",
|
||||
"controllers": "Controller",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "{button} konnte nicht ausgeführt werden: Keine Konfiguration festgelegt",
|
||||
"create": "Erstellen",
|
||||
@@ -134,7 +140,7 @@
|
||||
"enableAutoRotation": "Aktiviere die automatische Drehung auf Ihrem Gerät, um sicherzustellen, dass die App ordnungsgemäß funktioniert.",
|
||||
"enableBluetooth": "Bluetooth aktivieren",
|
||||
"enableKeyboardAccessMessage": "Aktiviere im folgenden Bildschirm die Tastatursteuerung für BikeControl. Falls BikeControl nicht angezeigt wird, füge es bitte manuell hinzu.",
|
||||
"enableKeyboardMouseControl": "Aktiviere die Tastatur- und Maussteuerung für eine bessere Interaktion mit {appName}. Sobald die Funktion aktiviert ist, sind keine weiteren Aktionen oder Verbindungen erforderlich. BikeControl sendet die Maus- oder Tastatureingaben direkt an {appName} weiter.",
|
||||
"enableKeyboardMouseControl": "BikeControl sendet Maus- oder Tastaturkommandos an {appName}",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
@@ -210,6 +216,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsGetYouSetUp": "Starten wir die Einrichtung",
|
||||
"license": "Lizenz",
|
||||
"licenseStatus": "Lizenzstatus",
|
||||
"loadScreenshotForPlacement": "Lade einen Screenshot aus dem Spiel zur Platzierung hoch.",
|
||||
@@ -235,7 +242,7 @@
|
||||
"myWhooshDirectConnectAction": "MyWhoosh „Link”-Aktion",
|
||||
"myWhooshDirectConnection": " z. B. mit MyWhoosh „Link”.",
|
||||
"myWhooshLinkConnected": "MyWhoosh „Link“ verbunden",
|
||||
"myWhooshLinkDescriptionLocal": "Verbinde dich direkt mit MyWhoosh über die „Link”-Methode. Unterstützte Aktionen sind unter anderem Schalten, Emotes und Richtungswechsel. Die MyWhoosh Link-Begleit-App darf dabei NICHT gleichzeitig laufen.",
|
||||
"myWhooshLinkDescriptionLocal": "Direkte Verbindung zu MyWhoosh über die „Link“-Methode. Beachte die Anleitung, um sicherzustellen, dass eine Verbindung möglich ist. Die „MyWhoosh Link“-App darf nicht gleichzeitig aktiv sein.",
|
||||
"myWhooshLinkInfo": "Schau mal im Abschnitt zur Fehlerbehebung nach, wenn du Probleme hast. Eine deutlich zuverlässigere Verbindungsmethode kommt bald!",
|
||||
"needHelpClickHelp": "Hilfe benötigt? Klicke auf",
|
||||
"needHelpDontHesitate": "den Button oben und zögere nicht, uns zu kontaktieren.",
|
||||
@@ -252,6 +259,7 @@
|
||||
}
|
||||
},
|
||||
"next": "Nächste",
|
||||
"no": "Nein",
|
||||
"noActionAssigned": "Keine Maßnahmen zugewiesen",
|
||||
"noActionAssignedForButton": "{button} konnte nicht ausgeführt werden: Keine Aktion zugewiesen",
|
||||
"noConnectionMethodIsConnectedOrActive": "Es ist keine Verbindungsmethode verbunden oder aktiv.",
|
||||
@@ -335,6 +343,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": {
|
||||
@@ -373,16 +382,17 @@
|
||||
"selectTrainerAppAndTarget": "Trainer-App und Zielgerät auswählen",
|
||||
"selectTrainerAppPlaceholder": "Trainer-App auswählen",
|
||||
"setting": "Einstellung",
|
||||
"setupComplete": "Einrichtung abgeschlossen!",
|
||||
"setupTrainer": "Trainer einrichten",
|
||||
"share": "Teilen",
|
||||
"showDonation": "Zeige Deine Wertschätzung durch eine Spende.",
|
||||
"showSupportedControllers": "Unterstützte Controller anzeigen",
|
||||
"showTroubleshootingGuide": "Anleitung zur Fehlerbehebung anzeigen",
|
||||
"signal": "Signal",
|
||||
"simulateButtons": "Trainersteuerung",
|
||||
"simulateKeyboardShortcut": "Tastenkombination simulieren",
|
||||
"simulateMediaKey": "Medientaste simulieren",
|
||||
"simulateTouch": "Berührung simulieren",
|
||||
"skip": "Überspringen",
|
||||
"stop": "Stoppen",
|
||||
"targetOtherDevice": "Anderes Gerät",
|
||||
"targetThisDevice": "Dieses Gerät",
|
||||
@@ -394,10 +404,11 @@
|
||||
"trialExpired": "Testphase abgelaufen. Befehle beschränkt auf {dailyCommandLimit} pro Tag.",
|
||||
"trialPeriodActive": "Testphase aktiv - {trialDaysRemaining} verbleibende Tage",
|
||||
"trialPeriodDescription": "Während der Testphase stehen unbegrenzt viele Befehle zur Verfügung. Nach Ablauf der Testphase sind die Befehle auf {dailyCommandLimit} pro Tag eingeschränkt.",
|
||||
"troubleshootingGuide": "Leitfaden zur Fehlerbehebung",
|
||||
"troubleshootingGuide": "Fragen und Antworten",
|
||||
"tryingToConnectAgain": "Verbinde erneut...",
|
||||
"unassignAction": "Zuweisung aufheben",
|
||||
"unlockFullVersion": "Vollversion freischalten",
|
||||
"unlockingNotPossible": "Aufgrund technischer Probleme ist eine Entsperrung derzeit nicht möglich; daher kann die App solange uneingeschränkt verwendet werden.",
|
||||
"update": "Aktualisieren",
|
||||
"useCustomKeymapForButton": "Verwende eine benutzerdefinierte Tastaturbelegung, um die",
|
||||
"version": "Version {version}",
|
||||
@@ -422,6 +433,7 @@
|
||||
},
|
||||
"whatsNew": "Was ist neu",
|
||||
"whyPermissionNeeded": "Wozu wird diese Berechtigung benötigt?",
|
||||
"yes": "Ja",
|
||||
"zwiftControllerAction": "Zwift Controller-Aktion",
|
||||
"zwiftControllerDescription": "Ermöglicht es BikeControl, als Zwift-kompatibler Controller zu fungieren."
|
||||
}
|
||||
@@ -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",
|
||||
@@ -20,6 +21,8 @@
|
||||
"allowLocationForBluetooth": "Allow Location so Bluetooth scan works",
|
||||
"allowPersistentNotification": "Allow Notifications",
|
||||
"allowsRunningInBackground": "Allows BikeControl to keep running in background",
|
||||
"alreadyBoughtTheApp": "Already bought the app? You don't need to pay for BikeControl, again. Due to technical difficulties, it's not possible to determine if the app was bought already.\n\nEnter your Play Store purchase ID (e.g. GPA.3356-1337-1338-1339) to unlock the full version. If you can't find it, please get in touch with me directly.",
|
||||
"alreadyBoughtTheAppPreviously": "Already bought the app previously?",
|
||||
"appIdActions": "{appId} actions",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
@@ -28,7 +31,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"asAFinalStepYoullChooseHowToConnectTo": "As a final step you'll choose how to connect to {trainerApp}.",
|
||||
"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 :(",
|
||||
@@ -73,6 +78,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllerConnectedClickButton": "Great! Your controller is connected. Click a button on your controller to continue.",
|
||||
"controllers": "Controllers",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Could not perform {button}: No keymap set",
|
||||
"create": "Create",
|
||||
@@ -134,7 +140,7 @@
|
||||
"enableAutoRotation": "Enable auto-rotation on your device to make sure the app works correctly.",
|
||||
"enableBluetooth": "Enable Bluetooth",
|
||||
"enableKeyboardAccessMessage": "Enable keyboard access in the following screen for BikeControl. If you don't see BikeControl, please add it manually.",
|
||||
"enableKeyboardMouseControl": "Enable keyboard and mouse control for better interaction with {appName}. Once active, no further action or connection is needed. BikeControl will directly send the mouse or keyboard input to {appName}.",
|
||||
"enableKeyboardMouseControl": "BikeControl will send mouse or keyboard actions to control {appName}.",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
@@ -210,6 +216,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsGetYouSetUp": "Let's get you set up!",
|
||||
"license": "License",
|
||||
"licenseStatus": "License Status",
|
||||
"loadScreenshotForPlacement": "Load in-game screenshot for placement",
|
||||
@@ -235,7 +242,7 @@
|
||||
"myWhooshDirectConnectAction": "MyWhoosh \"Link\" Action",
|
||||
"myWhooshDirectConnection": " e.g. by using MyWhoosh \"Link\"",
|
||||
"myWhooshLinkConnected": "MyWhoosh \"Link\" connected",
|
||||
"myWhooshLinkDescriptionLocal": "Connect directly to MyWhoosh via the \"Link\" method. Supported actions are shifting, Emotes, turn directions, among others. The MyWhoosh Link companion app must NOT be running at the same time.",
|
||||
"myWhooshLinkDescriptionLocal": "Connect directly to MyWhoosh via the \"Link\" method. Check the instructions to ensure a connection is possible. The \"MyWhoosh Link\" app must not be active at the same time.",
|
||||
"myWhooshLinkInfo": "Please check the troubleshooting section if you encounter any issues. A much more reliable connection method is coming soon!",
|
||||
"needHelpClickHelp": "Need help? Click on the",
|
||||
"needHelpDontHesitate": "button on top and don't hesitate to contact us.",
|
||||
@@ -252,6 +259,7 @@
|
||||
}
|
||||
},
|
||||
"next": "Next",
|
||||
"no": "No",
|
||||
"noActionAssigned": "No action assigned",
|
||||
"noActionAssignedForButton": "Could not perform {button}: No action assigned",
|
||||
"noConnectionMethodIsConnectedOrActive": "No connection method is connected or active.",
|
||||
@@ -335,6 +343,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": {
|
||||
@@ -373,16 +382,17 @@
|
||||
"selectTrainerAppAndTarget": "Select Trainer App & Target Device",
|
||||
"selectTrainerAppPlaceholder": "Select Trainer app",
|
||||
"setting": "Setting",
|
||||
"setupComplete": "Setup Complete!",
|
||||
"setupTrainer": "Setup Trainer",
|
||||
"share": "Share",
|
||||
"showDonation": "Show your appreciation by donating",
|
||||
"showSupportedControllers": "Show Supported Controllers",
|
||||
"showTroubleshootingGuide": "Show Troubleshooting Guide",
|
||||
"signal": "Signal",
|
||||
"simulateButtons": "Trainer Controls",
|
||||
"simulateKeyboardShortcut": "Simulate Keyboard shortcut",
|
||||
"simulateMediaKey": "Simulate Media key",
|
||||
"simulateTouch": "Simulate Touch",
|
||||
"skip": "Skip",
|
||||
"stop": "Stop",
|
||||
"targetOtherDevice": "Other Device",
|
||||
"targetThisDevice": "This Device",
|
||||
@@ -394,10 +404,11 @@
|
||||
"trialExpired": "Trial expired. Commands limited to {dailyCommandLimit} per day.",
|
||||
"trialPeriodActive": "Trial Period Active - {trialDaysRemaining} days remaining",
|
||||
"trialPeriodDescription": "Enjoy unlimited commands during your trial period. After the trial, commands will be limited to {dailyCommandLimit} per day.",
|
||||
"troubleshootingGuide": "Troubleshooting Guide",
|
||||
"troubleshootingGuide": "Questions & Answers",
|
||||
"tryingToConnectAgain": "Trying to connect again...",
|
||||
"unassignAction": "Unassign action",
|
||||
"unlockFullVersion": "Unlock Full Version",
|
||||
"unlockingNotPossible": "Due to technical issues unlocking is currently not possible, so enjoy unlimited usage for the time being!",
|
||||
"update": "Update",
|
||||
"useCustomKeymapForButton": "Use a custom keymap to support the",
|
||||
"version": "Version {version}",
|
||||
@@ -422,6 +433,7 @@
|
||||
},
|
||||
"whatsNew": "What's New",
|
||||
"whyPermissionNeeded": "Why is this permission needed?",
|
||||
"yes": "Yes",
|
||||
"zwiftControllerAction": "Zwift Controller Action",
|
||||
"zwiftControllerDescription": "Enables BikeControl to act as a Zwift-compatible controller."
|
||||
}
|
||||
@@ -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",
|
||||
@@ -20,6 +21,8 @@
|
||||
"allowLocationForBluetooth": "Autoriser la localisation pour que la recherche Bluetooth fonctionne",
|
||||
"allowPersistentNotification": "Autoriser les notifications",
|
||||
"allowsRunningInBackground": "Permet à BikeControl de continuer à fonctionner en arrière-plan",
|
||||
"alreadyBoughtTheApp": "Vous avez déjà acheté l'application ? Vous n'avez pas besoin de payer BikeControl une seconde fois. En raison de difficultés techniques, il est impossible de déterminer si l'application a déjà été achetée. \n\nSaisissez votre identifiant d'achat Play Store (par exemple : GPA.3356-1337-1338-1339) pour débloquer la version complète. Si vous ne le trouvez pas, veuillez me contacter directement.",
|
||||
"alreadyBoughtTheAppPreviously": "Vous avez déjà acheté l'application ?",
|
||||
"appIdActions": "{appId} actions",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
@@ -28,7 +31,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"asAFinalStepYoullChooseHowToConnectTo": "Dernière étape : vous choisirez comment vous connecter à {trainerApp} .",
|
||||
"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 :(",
|
||||
@@ -73,6 +78,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllerConnectedClickButton": "Super ! Votre manette est connectée. Cliquez sur un bouton de votre manette pour continuer.",
|
||||
"controllers": "Contrôleurs",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Impossible d'effectuer {button}: Aucun clavier défini",
|
||||
"create": "Créer",
|
||||
@@ -134,7 +140,7 @@
|
||||
"enableAutoRotation": "Activez la rotation automatique sur votre appareil pour vous assurer que l'application fonctionne correctement.",
|
||||
"enableBluetooth": "Activer le Bluetooth",
|
||||
"enableKeyboardAccessMessage": "Activez l'accès au clavier dans l'écran suivant pour BikeControl. Si vous ne voyez pas BikeControl, veuillez l'ajouter manuellement.",
|
||||
"enableKeyboardMouseControl": "Activez le contrôle du clavier et de la souris pour une meilleure interaction avec {appName} Une fois activé, aucune autre action ni connexion n'est nécessaire. BikeControl enverra directement les entrées de la souris ou du clavier à {appName} .",
|
||||
"enableKeyboardMouseControl": "BikeControl enverra des actions de souris ou de clavier pour contrôler {appName} .",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
@@ -210,6 +216,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsGetYouSetUp": "On va vous installer !",
|
||||
"license": "Licence",
|
||||
"licenseStatus": "Statut de la licence",
|
||||
"loadScreenshotForPlacement": "Charger une capture d'écran du jeu pour le placement",
|
||||
@@ -235,7 +242,7 @@
|
||||
"myWhooshDirectConnectAction": "Action «Link» de MyWhoosh",
|
||||
"myWhooshDirectConnection": " par exemple en utilisant MyWhoosh «Link».",
|
||||
"myWhooshLinkConnected": "MyWhoosh « Link » connecté",
|
||||
"myWhooshLinkDescriptionLocal": "Connecte-toi directement à MyWhoosh avec la méthode « Link ». Tu peux faire des trucs comme changer de vitesse, utiliser des émoticônes, indiquer la direction à prendre, et plein d'autres choses. L'appli MyWhoosh Link ne doit PAS être ouverte en même temps.",
|
||||
"myWhooshLinkDescriptionLocal": "Connectez-vous directement à MyWhoosh via la méthode « Link ». Consultez les instructions pour vérifier la compatibilité de la connexion. L’application « MyWhoosh Link » ne doit pas être active simultanément.",
|
||||
"myWhooshLinkInfo": "Si tu rencontres des problèmes, jette un œil à la section dépannage. Une méthode de connexion bien plus fiable sera bientôt disponible !",
|
||||
"needHelpClickHelp": "Besoin d'aide ? Cliquez sur le",
|
||||
"needHelpDontHesitate": "bouton en haut et n'hésitez pas à nous contacter.",
|
||||
@@ -252,6 +259,7 @@
|
||||
}
|
||||
},
|
||||
"next": "Suivant",
|
||||
"no": "Non",
|
||||
"noActionAssigned": "Aucune action assignée",
|
||||
"noActionAssignedForButton": "Impossible d'effectuer {button}: Aucune action assignée",
|
||||
"noConnectionMethodIsConnectedOrActive": "Aucune méthode de connexion n'est établie ou active.",
|
||||
@@ -335,6 +343,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": {
|
||||
@@ -373,16 +382,17 @@
|
||||
"selectTrainerAppAndTarget": "Sélectionnez l'application Trainer et l'appareil cible",
|
||||
"selectTrainerAppPlaceholder": "Sélectionner l'application Trainer",
|
||||
"setting": "Paramètre",
|
||||
"setupComplete": "Installation terminée !",
|
||||
"setupTrainer": "Configurer le Trainer",
|
||||
"share": "Partager",
|
||||
"showDonation": "Exprimez votre reconnaissance en faisant un don",
|
||||
"showSupportedControllers": "Afficher les manettes compatibles",
|
||||
"showTroubleshootingGuide": "Afficher le guide de dépannage",
|
||||
"signal": "Signal",
|
||||
"simulateButtons": "Commandes de l'entraîneur",
|
||||
"simulateKeyboardShortcut": "Simuler un raccourci clavier",
|
||||
"simulateMediaKey": "Simuler la touche média",
|
||||
"simulateTouch": "Simuler le toucher",
|
||||
"skip": "Sauter",
|
||||
"stop": "Arrêt",
|
||||
"targetOtherDevice": "Autre appareil",
|
||||
"targetThisDevice": "Cet appareil",
|
||||
@@ -394,10 +404,11 @@
|
||||
"trialExpired": "Période d'essai expirée. Commandes limitées à {dailyCommandLimit} par jour.",
|
||||
"trialPeriodActive": "Période d'essai active -{trialDaysRemaining} jours restants",
|
||||
"trialPeriodDescription": "Profitez de commandes illimitées pendant votre période d'essai. Après la période d'essai, le nombre de commandes sera limité à {dailyCommandLimit} par jour.",
|
||||
"troubleshootingGuide": "Guide de dépannage",
|
||||
"troubleshootingGuide": "Questions et réponses",
|
||||
"tryingToConnectAgain": "Tentative de reconnexion...",
|
||||
"unassignAction": "Action de désaffectation",
|
||||
"unlockFullVersion": "Débloquer la version complète",
|
||||
"unlockingNotPossible": "En raison de problèmes techniques, le déverrouillage est actuellement impossible. Profitez donc d'une utilisation illimitée pour le moment !",
|
||||
"update": "Mise à jour",
|
||||
"useCustomKeymapForButton": "Utilisez une configuration de touches personnalisée pour prendre en charge",
|
||||
"version": "Version {version}",
|
||||
@@ -422,6 +433,7 @@
|
||||
},
|
||||
"whatsNew": "Quoi de neuf",
|
||||
"whyPermissionNeeded": "Pourquoi cette autorisation est-elle nécessaire ?",
|
||||
"yes": "Oui",
|
||||
"zwiftControllerAction": "Action du contrôleur Zwift",
|
||||
"zwiftControllerDescription": "Permet à BikeControl de fonctionner comme une manette compatible avec Zwift."
|
||||
}
|
||||
439
lib/i10n/intl_it.arb
Normal file
439
lib/i10n/intl_it.arb
Normal file
@@ -0,0 +1,439 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl necessita dell'autorizzazione di accessibilità per controllare le tue app di allenamento.",
|
||||
"accessibilityDisclaimer": "BikeControl accederà al tuo schermo solo per eseguire i gesti da te configurati. Non accederà ad altre funzioni di accessibilità o a informazioni personali.",
|
||||
"accessibilityReasonControl": "• Per consentirti di controllare app come MyWhoosh, IndieVelo e altre utilizzando i tuoi dispositivi Zwift",
|
||||
"accessibilityReasonTouch": "• Per simulare i gesti touch sullo schermo per controllare le app di allenamento",
|
||||
"accessibilityReasonWindow": "• Per rilevare quale finestra dell'app di allenamento è attualmente attiva",
|
||||
"accessibilityServiceExplanation": "Per funzionare correttamente, BikeControl deve utilizzare l'API AccessibilityService di Android.",
|
||||
"accessibilityServiceNotRunning": "Il servizio di accessibilità non è in esecuzione. Seguire le istruzioni su",
|
||||
"accessibilityServicePermissionRequired": "Autorizzazione al servizio di accessibilità richiesta",
|
||||
"accessibilityUsageGestures": "• Quando premi i pulsanti sui tuoi dispositivi Zwift Click, Zwift Ride o Zwift Play, BikeControl simula i gesti touch in posizioni specifiche dello schermo",
|
||||
"accessibilityUsageMonitor": "• L'applicazione monitora quale finestra dell'app di allenamento è attiva per garantire che i gesti vengano inviati all'app corretta",
|
||||
"accessibilityUsageNoData": "• Nessun dato personale viene raccolto o consultato tramite questo servizio",
|
||||
"accessories": "Accessori",
|
||||
"action": "Azione",
|
||||
"adjustControllerButtons": "Regola i pulsanti del controller",
|
||||
"afterDate": "Dopo il {date}",
|
||||
"allow": "Consentire",
|
||||
"allowAccessibilityService": "Consenti servizio di accessibilità",
|
||||
"allowBluetoothConnections": "Consenti connessioni Bluetooth",
|
||||
"allowBluetoothScan": "Consenti scansione Bluetooth",
|
||||
"allowLocationForBluetooth": "Consentire in modo che la scansione Bluetooth funzioni",
|
||||
"allowPersistentNotification": "Consenti notifiche",
|
||||
"allowsRunningInBackground": "Consente a BikeControl di continuare a funzionare in background",
|
||||
"alreadyBoughtTheApp": "Hai già acquistato l'app? Non devi più pagare per BikeControl. A causa di problemi tecnici, non è possibile determinare se l'app è già stata acquistata.\n\nInserisci il tuo ID di acquisto sul Play Store (ad esempio GPA.3356-1337-1338-1339) per sbloccare la versione completa. Se non riesci a trovarlo, contattami direttamente.",
|
||||
"alreadyBoughtTheAppPreviously": "Hai già acquistato l'app in precedenza?",
|
||||
"appIdActions": "{appId} azioni",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"asAFinalStepYoullChooseHowToConnectTo": "Come ultimo passaggio sceglierai come connetterti a {trainerApp} .",
|
||||
"battery": "Batteria",
|
||||
"beforeDate": "Prima del {date}",
|
||||
"bluetoothAdvertiseAccess": "Accesso pubblicitario Bluetooth",
|
||||
"bluetoothTurnedOn": "Bluetooth attivato",
|
||||
"browserNotSupported": "Questo browser non supporta il Web Bluetooth e la piattaforma non è supportata :(",
|
||||
"button": "pulsante.",
|
||||
"cancel": "Cancella",
|
||||
"changelog": "Registro delle modifiche",
|
||||
"checkMyWhooshConnectionScreen": "Controlla la schermata di connessione in MyWhoosh per vedere se è connesso.",
|
||||
"chooseAnotherScreenshot": "Scegli un altro screenshot",
|
||||
"chooseBikeControlInConnectionScreen": "Selezionare BikeControl nella schermata di connessione.",
|
||||
"clickAButtonOnYourController": "Fai clic su un pulsante del controller per modificarne l'azione oppure tocca l'icona di modifica.",
|
||||
"clickV2EventInfo": "Il tuo Click V2 potrebbe non inviare più eventi tramite i pulsanti. Verifica toccando alcuni pulsanti e verifica se sono visibili in BikeControl.",
|
||||
"clickV2Instructions": "Per far funzionare al meglio il tuo Zwift Click V2, dovresti collegarlo all'app Zwift prima di ogni sessione di allenamento. In caso contrario, il Click V2 smetterà di funzionare dopo un minuto. \n1. Apri l'app Zwift \n2. Accedi (non richiesto un abbonamento attivo) e apri la schermata di connessione del dispositivo \n3. Collega il tuo trainer, quindi collega lo Zwift Click V2, verifica che i comandi del controller funzionino \n4. Chiudi nuovamente l'app Zwift e riconnettiti a BikeControl",
|
||||
"close": "Chiudi",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} comandi rimanenti oggi",
|
||||
"configuration": "Configurazione",
|
||||
"connectControllerToPreview": "Collega un dispositivo controller per visualizzare in anteprima e personalizzare la mappa dei tasti.",
|
||||
"connectControllers": "Connetti i controller",
|
||||
"connectDirectlyOverNetwork": "Connettiti direttamente tramite la rete",
|
||||
"connectToTrainerApp": "Connettiti all'app di allenamento",
|
||||
"connectUsingBluetooth": "Connettiti tramite Bluetooth",
|
||||
"connectUsingMyWhooshLink": "Connettiti tramite MyWhoosh \"Link\"",
|
||||
"connected": "Connesso",
|
||||
"connectedControllers": "Controller connessi",
|
||||
"connectedTo": "Connesso a{appId}",
|
||||
"@connectedTo": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connection": "Connessione",
|
||||
"continueAction": "Continua",
|
||||
"controlAppUsingModes": "Controllare{appName} usando{modes}",
|
||||
"@controlAppUsingModes": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"modes": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllerConnectedClickButton": "Ottimo! Il tuo controller è connesso. Clicca su un pulsante del controller per continuare.",
|
||||
"controllers": "Controller",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Non è stato possibile eseguire{button} : Nessuna mappa dei tasti impostata",
|
||||
"create": "Crea",
|
||||
"createNewKeymap": "Crea una nuova mappa dei tasti",
|
||||
"createNewProfileByDuplicating": "Crea un nuovo profilo personalizzato duplicando \"{profileName}\"",
|
||||
"@createNewProfileByDuplicating": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"createdNewCustomProfile": "Creato un nuovo profilo personalizzato:{profileName}",
|
||||
"@createdNewCustomProfile": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeControllerButtons": "Personalizza i pulsanti del controller per{appName}",
|
||||
"@customizeControllerButtons": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeKeymapHint": "Personalizza la mappa dei tasti, se riscontri problemi (ad esempio, output della tastiera errato o posizionamenti del tocco non allineati)",
|
||||
"dailyCommandLimitReachedNotification": "Limite giornaliero di comandi raggiunto per oggi. Esegui l'upgrade per sbloccare la versione completa con comandi illimitati.",
|
||||
"dailyLimitReached": "Limite giornaliero raggiunto ({dailyCommandCount} /{dailyCommandLimit} usato)",
|
||||
"delete": "Cancella",
|
||||
"deleteProfile": "Elimina profilo",
|
||||
"deleteProfileConfirmation": "Sei sicuro di voler eliminare \"{profileName}\" ? Questa azione non può essere annullata.",
|
||||
"@deleteProfileConfirmation": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deny": "Nega",
|
||||
"deviceButton": "{deviceName}pulsante",
|
||||
"@deviceButton": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disconnectDevices": "Dispositivi disconnessi",
|
||||
"disconnected": "Disconnesso",
|
||||
"donateByBuyingFromPlayStore": "acquistando l'app dal Play Store",
|
||||
"donateViaCreditCard": "tramite carta di credito, Google Pay, Apple Pay e altri",
|
||||
"donateViaPaypal": "tramite PayPal",
|
||||
"download": "Download",
|
||||
"dragToReposition": "Trascina per riposizionare",
|
||||
"duplicate": "Duplica",
|
||||
"enableAutoRotation": "Abilita la rotazione automatica sul tuo dispositivo per assicurarti che l'app funzioni correttamente.",
|
||||
"enableBluetooth": "Abilita Bluetooth",
|
||||
"enableKeyboardAccessMessage": "Abilita l'accesso tramite tastiera nella schermata seguente per BikeControl. Se BikeControl non è presente, aggiungilo manualmente.",
|
||||
"enableKeyboardMouseControl": "BikeControl invierà azioni del mouse o della tastiera per controllare {appName} .",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableMediaKeyDetection": "Abilita rilevamento tasti multimediali",
|
||||
"enablePairingProcess": "Abilita processo di associazione",
|
||||
"enablePermissions": "Abilita autorizzazioni",
|
||||
"enableSteeringWithPhone": "Abilita i sensori dei telefoni per abilitare ad esempio lo sterzo",
|
||||
"enableVibrationFeedback": "Abilita il feedback delle vibrazioni durante il cambio marcia",
|
||||
"enableZwiftControllerBluetooth": "Abilita il controller Zwift (Bluetooth)",
|
||||
"enableZwiftControllerNetwork": "Abilita Zwift Controller (rete)",
|
||||
"errorStartingMyWhooshLink": "Errore durante l'avvio del server MyWhoosh Link. Assicurati che l'app \"MyWhoosh Link\" non sia già in esecuzione su questo dispositivo.",
|
||||
"errorStartingOpenBikeControlBluetoothServer": "Errore durante l'avvio del server Bluetooth OpenBikeControl.",
|
||||
"errorStartingOpenBikeControlServer": "Errore durante l'avvio del server OpenBikeControl.",
|
||||
"exportAction": "Esporta",
|
||||
"failedToImportProfile": "Impossibile importare il profilo. Formato non valido.",
|
||||
"failedToUpdate": "Aggiornamento non riuscito:{error}",
|
||||
"@failedToUpdate": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firmware": "Firmware",
|
||||
"forceCloseToUpdate": "Forza la chiusura dell'app per utilizzare la nuova versione",
|
||||
"fullVersion": "Versione completa",
|
||||
"fullVersionDescription": "La versione completa include: \n- Comandi illimitati al giorno \n- Accesso a tutti gli aggiornamenti futuri \n- Nessun abbonamento! Solo un canone una tantum :)",
|
||||
"getSupport": "Ottieni supporto",
|
||||
"gotIt": "Fatto!",
|
||||
"grant": "Concedi",
|
||||
"granted": "Concesso",
|
||||
"helpRequested": "Aiuto richiesto per BikeControl v{version}",
|
||||
"@helpRequested": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"howBikeControlUsesPermission": "In che modo BikeControl utilizza questa autorizzazione?",
|
||||
"ignoredDevices": "Dispositivi ignorati",
|
||||
"importAction": "Importa",
|
||||
"importProfile": "Importa profilo",
|
||||
"instructions": "Istruzioni",
|
||||
"jsonData": "Dati JSON",
|
||||
"keyboardAccess": "Accesso tramite tastiera",
|
||||
"latestVersion": "ultima:{version}",
|
||||
"@latestVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveAReview": "Lascia una recensione",
|
||||
"letsAppConnectOverBluetooth": "Lascia che {appName} si connetta a BikeControl tramite Bluetooth.",
|
||||
"@letsAppConnectOverBluetooth": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsAppConnectOverNetwork": "Lascia che {appName} si connetta direttamente tramite la rete. Seleziona BikeControl nella schermata di connessione.",
|
||||
"@letsAppConnectOverNetwork": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsGetYouSetUp": "Ti aiuteremo a prepararti!",
|
||||
"license": "Licenza",
|
||||
"licenseStatus": "Stato della licenza",
|
||||
"loadScreenshotForPlacement": "Carica lo screenshot del gioco per il posizionamento",
|
||||
"logViewer": "Visualizzatore di registri",
|
||||
"logs": "Registri",
|
||||
"logsAreAlsoAt": "I registri sono anche a",
|
||||
"logsHaveBeenCopiedToClipboard": "I registri sono stati copiati negli appunti",
|
||||
"longPress": "pressione prolungata",
|
||||
"longPressMode": "Modalità pressione prolungata (rispetto alla ripetizione)",
|
||||
"mailSupportExplanation": "Fornire supporto individuale via email è un impegno notevole per me. \n\nVi invito a utilizzare Reddit, Facebook o GitHub per domande e problemi, in modo che l'intera comunità possa trarne beneficio.",
|
||||
"manageIgnoredDevices": "Gestisci dispositivi ignorati",
|
||||
"manageProfile": "Gestisci profilo",
|
||||
"manualyControllingButton": "Controlla {trainerApp} manualmente!",
|
||||
"mediaKeyDetectionTooltip": "Abilita questa opzione per consentire a BikeControl di rilevare i telecomandi Bluetooth. \nPer fare ciò, BikeControl deve funzionare come lettore multimediale.",
|
||||
"miuiDeviceDetected": "Dispositivo MIUI rilevato",
|
||||
"miuiDisableBatteryOptimization": "• Disattivare l'ottimizzazione della batteria per BikeControl",
|
||||
"miuiEnableAutostart": "• Abilita l'avvio automatico per BikeControl",
|
||||
"miuiEnsureProperWorking": "Per garantire il corretto funzionamento di BikeControl:",
|
||||
"miuiLockInRecentApps": "• Blocca l'app nelle app recenti",
|
||||
"miuiWarningDescription": "Il tuo dispositivo utilizza MIUI, che è noto per disattivare in modo aggressivo i servizi in background e i servizi di accessibilità.",
|
||||
"moreInformation": "Ulteriori informazioni",
|
||||
"mustChooseAllowOrDeny": "Per continuare, devi scegliere se consentire o negare questa autorizzazione.",
|
||||
"myWhooshDirectConnectAction": "Azione MyWhoosh \"Link\"",
|
||||
"myWhooshDirectConnection": " ad esempio utilizzando MyWhoosh \"Link\"",
|
||||
"myWhooshLinkConnected": "MyWhoosh \"Link\" connesso",
|
||||
"myWhooshLinkDescriptionLocal": "Connettiti direttamente a MyWhoosh tramite il metodo \"Link\". Controlla le istruzioni per assicurarti che la connessione sia possibile. L'app \"MyWhoosh Link\" non deve essere attiva contemporaneamente.",
|
||||
"myWhooshLinkInfo": "In caso di problemi, consulta la sezione relativa alla risoluzione dei problemi. Presto sarà disponibile un metodo di connessione molto più affidabile!",
|
||||
"needHelpClickHelp": "Hai bisogno di aiuto? Clicca sul",
|
||||
"needHelpDontHesitate": "pulsante in alto e non esitare a contattarci.",
|
||||
"newConnectionMethodAnnouncement": "{trainerApp}supporterà presto metodi di connessione molto migliori e affidabili: restate sintonizzati per gli aggiornamenti!",
|
||||
"newCustomProfile": "Nuovo profilo personalizzato",
|
||||
"newProfileName": "Nuovo nome del profilo",
|
||||
"newVersionAvailable": "Nuova versione disponibile",
|
||||
"newVersionAvailableWithVersion": "Nuova versione disponibile:{version}",
|
||||
"@newVersionAvailableWithVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"next": "Successivo",
|
||||
"no": "No",
|
||||
"noActionAssigned": "Nessuna azione assegnata",
|
||||
"noActionAssignedForButton": "Non è stato possibile eseguire{button} : Nessuna azione assegnata",
|
||||
"noConnectionMethodIsConnectedOrActive": "Nessun metodo di connessione è connesso o attivo.",
|
||||
"noConnectionMethodSelected": "Nessun metodo di connessione selezionato",
|
||||
"noControllerConnected": "Nessuno connesso",
|
||||
"noControllerUseCompanionMode": "Non hai un controller? Usa la Companion Mode",
|
||||
"noIgnoredDevices": "Nessun dispositivo ignorato.",
|
||||
"noTrainerSelected": "Nessun Trainer selezionato",
|
||||
"notConnected": "Non connesso",
|
||||
"notificationDescription": "In questo modo l'app rimane attiva in background e ti aggiorna quando cambia la connessione ai tuoi dispositivi.",
|
||||
"ok": "Ok",
|
||||
"openBikeControlActions": "Azioni OpenBikeControl",
|
||||
"openBikeControlAnnouncement": "Ottime notizie -{trainerApp} supporta il protocollo OpenBikeControl, così avrai la migliore esperienza possibile!",
|
||||
"openBikeControlConnection": " ad esempio utilizzando la connessione OpenBikeControl",
|
||||
"otherConnectionMethods": "Altri metodi di connessione",
|
||||
"pairingDescription": "L'associazione consente la personalizzazione completa, ma potrebbe non funzionare su tutti i dispositivi.",
|
||||
"pairingInstructions": "Sul tuo{targetName} Accedi alle impostazioni Bluetooth e cerca BikeControl o il nome del tuo dispositivo. L'associazione è necessaria se vuoi utilizzare la funzione di controllo remoto.",
|
||||
"@pairingInstructions": {
|
||||
"placeholders": {
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pairingInstructionsIOS": "Sul tuo iPad, vai su Impostazioni > Accessibilità > Tocco > Assistenza Tocco > Dispositivi di puntamento > Dispositivi e associa il tuo dispositivo. Assicurati che Assistenza Tocco sia abilitato.",
|
||||
"pasteExportedJsonData": "Incolla i dati JSON esportati qui sotto:",
|
||||
"pathCopiedToClipboard": "Il percorso è stato copiato negli appunti",
|
||||
"permissionsRequired": "Per consentire a BikeControl di cercare i dispositivi nelle vicinanze e di aggiornarti quando cambia la connessione, abilita le seguenti autorizzazioni:",
|
||||
"platformNotSupported": "Questo{platform} non è supportato :(",
|
||||
"@platformNotSupported": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"platformRestrictionNotSupported": "A causa delle restrizioni della piattaforma, questo scenario non è supportato.",
|
||||
"platformRestrictionOtherDevicesOnly": "A causa delle restrizioni della piattaforma, solo il controllo{appName} su altri dispositivi è supportato.",
|
||||
"@platformRestrictionOtherDevicesOnly": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playPause": "Riproduci/Pausa",
|
||||
"pleaseSelectAConnectionMethodFirst": "Per prima cosa seleziona un metodo di connessione nelle impostazioni del Trainer.",
|
||||
"predefinedAction": "Predefinito{appName} azione",
|
||||
"@predefinedAction": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressButtonOnClickDevice": "Premi un pulsante sul tuo dispositivo Click",
|
||||
"pressKeyToAssign": "Premi un tasto sulla tastiera per assegnarlo a{buttonName}",
|
||||
"@pressKeyToAssign": {
|
||||
"placeholders": {
|
||||
"buttonName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"previous": "Precedente",
|
||||
"profileExportedToClipboard": "Profilo \"{profileName}\" esportato negli appunti",
|
||||
"@profileExportedToClipboard": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileImportedSuccessfully": "Profilo importato con successo",
|
||||
"profileName": "Nome del profilo",
|
||||
"purchase": "Acquista",
|
||||
"recommendedConnectionMethods": "Metodi di connessione consigliati",
|
||||
"removeFromIgnoredList": "Rimuovi dall'elenco degli ignorati",
|
||||
"rename": "Rinomina",
|
||||
"renameProfile": "Rinomina profilo",
|
||||
"requirement": "Requisiti",
|
||||
"reset": "Reset",
|
||||
"restart": "Riavvia",
|
||||
"restorePurchaseInfo": "Clicca sul pulsante qui sopra, poi su \"Ripristina acquisto\". In caso di problemi, contattami direttamente.",
|
||||
"runAppOnPlatformRemotely": "Avvia {appName} su{platform} e controllarlo da remoto da questo dispositivo{preferredConnection} .",
|
||||
"@runAppOnPlatformRemotely": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"platform": {
|
||||
"type": "String"
|
||||
},
|
||||
"preferredConnection": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runAppOnThisDevice": "Avvia {appName} su questo dispositivo.",
|
||||
"@runAppOnThisDevice": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"save": "Salva",
|
||||
"scan": "Scansione",
|
||||
"scanningForDevices": "Ricerca dispositivi in corso... Assicurarsi che siano accesi e nel raggio d'azione e che non siano connessi a un altro dispositivo.",
|
||||
"selectKeymap": "Seleziona la mappa dei tasti",
|
||||
"selectTargetWhereAppRuns": "Seleziona la destinazione dove{appName} è in esecuzione",
|
||||
"@selectTargetWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTrainerApp": "Seleziona l'app Trainer",
|
||||
"selectTrainerAppAndTarget": "Seleziona l'app Trainer e il dispositivo di destinazione",
|
||||
"selectTrainerAppPlaceholder": "Seleziona l'app Trainer",
|
||||
"setting": "Impostazioni",
|
||||
"setupComplete": "Installazione completata!",
|
||||
"setupTrainer": "Impostazioni Trainer",
|
||||
"share": "Condividi",
|
||||
"showDonation": "Mostra il tuo apprezzamento donando",
|
||||
"showSupportedControllers": "Mostra controller supportati",
|
||||
"signal": "Segnale",
|
||||
"simulateButtons": "Controlli del Trainer",
|
||||
"simulateKeyboardShortcut": "Simula scorciatoia da tastiera",
|
||||
"simulateMediaKey": "Simula il tasto multimediale",
|
||||
"simulateTouch": "Simula il tocco",
|
||||
"skip": "Saltare",
|
||||
"stop": "Stop",
|
||||
"targetOtherDevice": "Altro dispositivo",
|
||||
"targetThisDevice": "Questo dispositivo",
|
||||
"theFollowingPermissionsRequired": "Sono richieste le seguenti autorizzazioni:",
|
||||
"touchAreaInstructions": "1. Crea uno screenshot in-game della tua app (ad esempio all'interno di MyWhoosh) in orientamento orizzontale \n2. Carica lo screenshot con il pulsante qui sotto \n3. L'app viene automaticamente impostata in orientamento orizzontale per una mappatura accurata \n4. Premi un pulsante sul tuo dispositivo Click per creare un'area touch \n5. Trascina le aree touch nella posizione desiderata sullo screenshot \n6. Salva e chiudi questa schermata",
|
||||
"touchSimulationForegroundMessage": "Per simulare i tocchi, l'app deve rimanere in primo piano.",
|
||||
"trainer": "Trainer",
|
||||
"trialDaysRemaining": "{trialDaysRemaining}giorni rimanenti",
|
||||
"trialExpired": "Prova scaduta. Comandi limitati a{dailyCommandLimit} al giorno.",
|
||||
"trialPeriodActive": "Periodo di prova attivo -{trialDaysRemaining} giorni rimanenti",
|
||||
"trialPeriodDescription": "Goditi comandi illimitati durante il periodo di prova. Dopo la prova, i comandi saranno limitati a{dailyCommandLimit} al giorno.",
|
||||
"troubleshootingGuide": "Domande e risposte",
|
||||
"tryingToConnectAgain": "Sto provando a connettermi di nuovo...",
|
||||
"unassignAction": "Annulla assegnazione azione",
|
||||
"unlockFullVersion": "Sblocca la versione completa",
|
||||
"unlockingNotPossible": "A causa di problemi tecnici, al momento non è possibile sbloccare il tutto, ma per il momento puoi usufruire di un utilizzo illimitato!",
|
||||
"update": "Aggiorna",
|
||||
"useCustomKeymapForButton": "Utilizzare una mappa dei tasti personalizzata per supportare",
|
||||
"version": "Versione{version}",
|
||||
"@version": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewDetailedInstructions": "Visualizza le istruzioni dettagliate",
|
||||
"volumeDown": "Abbassa il volume",
|
||||
"volumeUp": "Aumenta il volume",
|
||||
"waiting": "In attesa...",
|
||||
"waitingForConnectionKickrBike": "In attesa di connessione. Scegli KICKR BIKE PRO in{appName} menu di associazione del controller.",
|
||||
"@waitingForConnectionKickrBike": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"whatsNew": "Novità",
|
||||
"whyPermissionNeeded": "Perché è necessaria questa autorizzazione?",
|
||||
"yes": "SÌ",
|
||||
"zwiftControllerAction": "Azione del controller Zwift",
|
||||
"zwiftControllerDescription": "Consente a BikeControl di funzionare come controller compatibile con Zwift."
|
||||
}
|
||||
@@ -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",
|
||||
@@ -20,6 +21,8 @@
|
||||
"allowLocationForBluetooth": "Zezwól na dostęp do lokalizacji, aby umożliwić skanowanie Bluetooth",
|
||||
"allowPersistentNotification": "Zezwól na powiadomienia",
|
||||
"allowsRunningInBackground": "Umożliwia działanie BikeControl w tle",
|
||||
"alreadyBoughtTheApp": "Kupiłeś już aplikację? Nie musisz płacić za BikeControl. Z powodu problemów technicznych nie można ustalić, czy aplikacja została już zakupiona. \n\nWprowadź swój identyfikator zakupu w Sklepie Play (np. GPA.3356-1337-1338-1339), aby odblokować pełną wersję. Jeśli nie możesz jej znaleźć, skontaktuj się ze mną bezpośrednio.",
|
||||
"alreadyBoughtTheAppPreviously": "Już wcześniej kupiłeś aplikację?",
|
||||
"appIdActions": "{appId} działania",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
@@ -28,13 +31,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"asAFinalStepYoullChooseHowToConnectTo": "W ostatnim kroku wybierzesz sposób połączenia {trainerApp} .",
|
||||
"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 :(",
|
||||
"button": "przycisk.",
|
||||
"cancel": "Anuluj",
|
||||
"changelog": "Changelog",
|
||||
"changelog": "Dziennik zmian",
|
||||
"checkMyWhooshConnectionScreen": "Sprawdź ekran połączenia w MyWhoosh, aby zobaczyć, czy „Link” jest połączony.",
|
||||
"chooseAnotherScreenshot": "Wybierz inny zrzut ekranu",
|
||||
"chooseBikeControlInConnectionScreen": "Wybierz BikeControl na ekranie połączenia.",
|
||||
@@ -73,6 +78,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllerConnectedClickButton": "Świetnie! Twój kontroler jest połączony. Kliknij przycisk na kontrolerze, aby kontynuować.",
|
||||
"controllers": "Kontrolery",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Nie można wykonać {button}: Nie zmapowano klawisza",
|
||||
"create": "Utwórz",
|
||||
@@ -134,7 +140,7 @@
|
||||
"enableAutoRotation": "Włącz funkcję automatycznego obracania na urządzeniu, aby mieć pewność, że aplikacja będzie działać prawidłowo.",
|
||||
"enableBluetooth": "Włącz Bluetooth",
|
||||
"enableKeyboardAccessMessage": "Włącz dostęp do klawiatury dla BikeControl na poniższym ekranie. Jeśli nie widzisz BikeControl, dodaj go ręcznie.",
|
||||
"enableKeyboardMouseControl": "Włącz sterowanie klawiaturą i myszą, aby zapewnić lepszą interakcję z {appName}. Po aktywacji nie jest wymagane żadne dalsze działanie, ani połączenie. BikeControl będzie bezpośrednio wysyłał dane z myszy lub klawiatury do {appName}.",
|
||||
"enableKeyboardMouseControl": "BikeControl będzie wysyłał akcje myszy lub klawiatury do kontrolera {appName} .",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
@@ -210,10 +216,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsGetYouSetUp": "Przygotujmy Cię!",
|
||||
"license": "Licencja",
|
||||
"licenseStatus": "Status licencji",
|
||||
"loadScreenshotForPlacement": "Prześlij zrzut ekranu z gry w celu ustalenia miejsca",
|
||||
"logViewer": "Przeglądarka dziennika zdarzeń",
|
||||
"logViewer": "Podgląd dziennika zdarzeń",
|
||||
"logs": "Dziennik zdarzeń",
|
||||
"logsAreAlsoAt": "Dziennik zdarzeń jest dostępny również na",
|
||||
"logsHaveBeenCopiedToClipboard": "Dziennik zdarzeń został skopiowany do schowka",
|
||||
@@ -235,11 +242,11 @@
|
||||
"myWhooshDirectConnectAction": "Akcja „Link” MyWhoosh",
|
||||
"myWhooshDirectConnection": " np. za pomocą MyWhoosh „Link”",
|
||||
"myWhooshLinkConnected": "Połączono MyWhoosh „Link”",
|
||||
"myWhooshLinkDescriptionLocal": "Połącz się bezpośrednio z MyWhoosh za pomocą „Link”. Obsługiwane akcje to m.in. zmiana biegów, emotikony i zmiana kierunku. Aplikacja MyWhoosh Link NIE może być uruchomiona w tym samym czasie.",
|
||||
"myWhooshLinkDescriptionLocal": "Połącz się bezpośrednio z MyWhoosh za pomocą metody „Link”. Sprawdź instrukcje, aby upewnić się, że połączenie jest możliwe. Aplikacja „MyWhoosh Link” nie może być jednocześnie aktywna.",
|
||||
"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!",
|
||||
"needHelpDontHesitate": "przycisk na górze i skontaktuj się z nami.",
|
||||
"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",
|
||||
@@ -252,6 +259,7 @@
|
||||
}
|
||||
},
|
||||
"next": "Dalej",
|
||||
"no": "Nie",
|
||||
"noActionAssigned": "Brak przypisanej akcji",
|
||||
"noActionAssignedForButton": "Nie można wykonać {button}: Brak przypisanej akcji",
|
||||
"noConnectionMethodIsConnectedOrActive": "Żadna metoda połączenia nie jest podłączona lub aktywna.",
|
||||
@@ -259,7 +267,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",
|
||||
@@ -298,7 +306,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": {
|
||||
@@ -335,6 +343,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": {
|
||||
@@ -373,16 +382,17 @@
|
||||
"selectTrainerAppAndTarget": "Wybierz aplikację treningową i docelowe urządzenie",
|
||||
"selectTrainerAppPlaceholder": "Wybierz aplikację treningową",
|
||||
"setting": "Ustawienie",
|
||||
"setupComplete": "Konfiguracja ukończona!",
|
||||
"setupTrainer": "Konfiguracja trenażera",
|
||||
"share": "Udostępnij",
|
||||
"showDonation": "Wyraź swoją wdzięczność poprzez darowiznę",
|
||||
"showSupportedControllers": "Pokaż wspierane kontrolery",
|
||||
"showTroubleshootingGuide": "Pokaż instrukcję rozwiązywania problemów",
|
||||
"signal": "Sygnał",
|
||||
"simulateButtons": "Sterowanie trenażerem",
|
||||
"simulateKeyboardShortcut": "Symuluj skrót klawiaturowy",
|
||||
"simulateMediaKey": "Symuluj przycisk multimedialny",
|
||||
"simulateTouch": "Symuluj dotyk",
|
||||
"skip": "Pominąć",
|
||||
"stop": "Stop",
|
||||
"targetOtherDevice": "Inne urządzenie",
|
||||
"targetThisDevice": "To urządzenie",
|
||||
@@ -394,10 +404,11 @@
|
||||
"trialExpired": "Wersja próbna wygasła. Liczba poleceń ograniczona do {dailyCommandLimit} na dzień.",
|
||||
"trialPeriodActive": "Okres próbny jest aktywny - pozostało {trialDaysRemaining} dni",
|
||||
"trialPeriodDescription": "Korzystaj z nieograniczonej liczby poleceń w okresie próbnym. Po zakończeniu okresu próbnego polecenia będą ograniczone do {dailyCommandLimit} na dzień.",
|
||||
"troubleshootingGuide": "Instrukcja rozwiązywania problemów",
|
||||
"troubleshootingGuide": "Pytania i odpowiedzi",
|
||||
"tryingToConnectAgain": "Próba ponownego połączenia...",
|
||||
"unassignAction": "Anuluj przypisanie akcji",
|
||||
"unlockFullVersion": "Odblokuj pełną wersję",
|
||||
"unlockingNotPossible": "Z uwagi na problemy techniczne odblokowanie nie jest obecnie możliwe, więc ciesz się nieograniczonym użytkowaniem!",
|
||||
"update": "Aktualizacja",
|
||||
"useCustomKeymapForButton": "Użyj niestandardowej mapy klawiszy, aby obsługiwać",
|
||||
"version": "Wersja {version}",
|
||||
@@ -422,6 +433,7 @@
|
||||
},
|
||||
"whatsNew": "Co nowego",
|
||||
"whyPermissionNeeded": "Dlaczego to uprawnienie jest potrzebne?",
|
||||
"yes": "Tak",
|
||||
"zwiftControllerAction": "Akcja kontrolera Zwift",
|
||||
"zwiftControllerDescription": "Umożliwia BikeControl działanie jako kontroler kompatybilny ze Zwift."
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'dart:isolate';
|
||||
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/onboarding.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/actions/remote.dart';
|
||||
@@ -11,8 +12,6 @@ import 'package:bike_control/widgets/menu.dart';
|
||||
import 'package:bike_control/widgets/testbed.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart'
|
||||
show GlobalMaterialLocalizations, GlobalWidgetsLocalizations;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import 'pages/navigation.dart';
|
||||
@@ -105,7 +104,7 @@ Future<void> _persistCrash({
|
||||
..writeln('Error: $error')
|
||||
..writeln('Stack: ${stack ?? 'no stack'}')
|
||||
..writeln('Info: ${information ?? ''}')
|
||||
..writeln(debugText())
|
||||
..writeln(await debugText())
|
||||
..writeln()
|
||||
..writeln();
|
||||
|
||||
@@ -165,12 +164,19 @@ void initializeActions(ConnectionType connectionType) {
|
||||
core.actionHandler.init(core.settings.getKeyMap());
|
||||
}
|
||||
|
||||
class BikeControlApp extends StatelessWidget {
|
||||
class BikeControlApp extends StatefulWidget {
|
||||
final Widget? customChild;
|
||||
final BCPage page;
|
||||
final String? error;
|
||||
const BikeControlApp({super.key, this.error, this.page = BCPage.devices, this.customChild});
|
||||
|
||||
@override
|
||||
State<BikeControlApp> createState() => _BikeControlAppState();
|
||||
}
|
||||
|
||||
class _BikeControlAppState extends State<BikeControlApp> {
|
||||
BCPage? _showPage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.sizeOf(context).width < 600;
|
||||
@@ -180,9 +186,8 @@ class BikeControlApp extends StatelessWidget {
|
||||
menuHandler: PopoverOverlayHandler(),
|
||||
popoverHandler: PopoverOverlayHandler(),
|
||||
localizationsDelegates: [
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
ShadcnLocalizations.delegate,
|
||||
...ShadcnLocalizations.localizationsDelegates,
|
||||
OtherLocalizationsDelegate(),
|
||||
AppLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
@@ -200,23 +205,75 @@ class BikeControlApp extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
//themeMode: ThemeMode.dark,
|
||||
home: error != null
|
||||
home: widget.error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
'There was an error starting the App. Please contact support:\n$error',
|
||||
'There was an error starting the App. Please contact support:\n${widget.error}',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
)
|
||||
: ToastLayer(
|
||||
key: ValueKey('Test'),
|
||||
padding: isMobile ? EdgeInsets.only(bottom: 60, left: 24, right: 24, top: 60) : null,
|
||||
child: Stack(
|
||||
children: [
|
||||
customChild ?? Navigation(page: page),
|
||||
Positioned.fill(child: Testbed()),
|
||||
],
|
||||
child: _Starter(
|
||||
child: Stack(
|
||||
children: [
|
||||
widget.customChild ??
|
||||
(AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 600),
|
||||
child: core.settings.getShowOnboarding()
|
||||
? OnboardingPage(
|
||||
onComplete: () {
|
||||
setState(() {
|
||||
_showPage = BCPage.trainer;
|
||||
});
|
||||
},
|
||||
)
|
||||
: Navigation(page: _showPage ?? widget.page),
|
||||
)),
|
||||
Positioned.fill(child: Testbed()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Starter extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _Starter({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<_Starter> createState() => _StarterState();
|
||||
}
|
||||
|
||||
class _StarterState extends State<_Starter> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
core.connection.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
class OtherLocalizationsDelegate extends LocalizationsDelegate<ShadcnLocalizations> {
|
||||
const OtherLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) =>
|
||||
AppLocalizations.delegate.supportedLocales.map((e) => e.languageCode).contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
Future<ShadcnLocalizations> load(Locale locale) async {
|
||||
return SynchronousFuture<ShadcnLocalizations>(lookupShadcnLocalizations(Locale('en')));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReload(covariant LocalizationsDelegate<ShadcnLocalizations> old) => false;
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
child: Text(context.i18n.playPause),
|
||||
leading: Icon(Icons.play_arrow_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaPlayPause;
|
||||
_keyPair.logicalKey = null;
|
||||
@@ -277,9 +277,10 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.playPause),
|
||||
),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.stop),
|
||||
leading: Icon(Icons.stop_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaStop;
|
||||
_keyPair.logicalKey = null;
|
||||
@@ -287,10 +288,10 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.stop),
|
||||
),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.previous),
|
||||
|
||||
leading: Icon(Icons.skip_previous_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackPrevious;
|
||||
_keyPair.logicalKey = null;
|
||||
@@ -298,9 +299,10 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.previous),
|
||||
),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.next),
|
||||
leading: Icon(Icons.skip_next_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackNext;
|
||||
_keyPair.logicalKey = null;
|
||||
@@ -308,8 +310,10 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.next),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.volume_up_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeUp;
|
||||
_keyPair.logicalKey = null;
|
||||
@@ -320,6 +324,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
child: Text(context.i18n.volumeUp),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.volume_down_outlined),
|
||||
child: Text(context.i18n.volumeDown),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeDown;
|
||||
@@ -434,6 +439,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
title: Text(action.title),
|
||||
subtitle: (action.possibleValues != null && action == _keyPair.inGameAction)
|
||||
? Text(_keyPair.inGameActionValue!.toString())
|
||||
: action.alternativeTitle != null
|
||||
? Text(action.alternativeTitle!)
|
||||
: null,
|
||||
isActive: _keyPair.inGameAction == action && supportedActions.contains(_keyPair.inGameAction),
|
||||
onPressed: () {
|
||||
@@ -517,7 +524,7 @@ class SelectableCard extends StatelessWidget {
|
||||
hoverColor: Theme.of(context).colorScheme.card,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
alignment: Alignment.centerLeft,
|
||||
alignment: Alignment.topLeft,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
|
||||
@@ -86,6 +86,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
InGameAction? _pressedAction;
|
||||
|
||||
DateTime? _lastDown;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -98,6 +99,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -200,7 +202,9 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
),
|
||||
],
|
||||
child: Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -473,7 +477,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
if (!down && _lastDown != null) {
|
||||
if (!down && _lastDown != null && action.isLongPress) {
|
||||
final timeSinceLastDown = DateTime.now().difference(_lastDown!);
|
||||
if (timeSinceLastDown < Duration(milliseconds: 400)) {
|
||||
// wait a bit so actions actually get applied correctly for some trainer apps
|
||||
|
||||
@@ -16,8 +16,9 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ConfigurationPage extends StatefulWidget {
|
||||
final bool onboardingMode;
|
||||
final VoidCallback onUpdate;
|
||||
const ConfigurationPage({super.key, required this.onUpdate});
|
||||
const ConfigurationPage({super.key, required this.onUpdate, this.onboardingMode = false});
|
||||
|
||||
@override
|
||||
State<ConfigurationPage> createState() => _ConfigurationPageState();
|
||||
@@ -28,6 +29,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 12,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -111,7 +113,9 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
},
|
||||
),
|
||||
if (core.settings.getTrainerApp() != null) ...[
|
||||
if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol == true && !screenshotMode)
|
||||
if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol == true &&
|
||||
!screenshotMode &&
|
||||
!widget.onboardingMode)
|
||||
Text(
|
||||
AppLocalizations.of(context).openBikeControlAnnouncement(core.settings.getTrainerApp()!.name),
|
||||
).xSmall,
|
||||
@@ -162,7 +166,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
],
|
||||
),
|
||||
],
|
||||
if (core.settings.getTrainerApp()?.star == true && !screenshotMode)
|
||||
if (core.settings.getTrainerApp()?.star == true && !screenshotMode && !widget.onboardingMode)
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
|
||||
@@ -13,7 +13,8 @@ import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class CustomizePage extends StatefulWidget {
|
||||
const CustomizePage({super.key});
|
||||
final bool isMobile;
|
||||
const CustomizePage({super.key, required this.isMobile});
|
||||
|
||||
@override
|
||||
State<CustomizePage> createState() => _CustomizeState();
|
||||
@@ -23,7 +24,7 @@ class _CustomizeState extends State<CustomizePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
padding: EdgeInsets.only(bottom: widget.isMobile ? 146 : 16, left: 16, right: 16, top: 16),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
@@ -44,77 +45,86 @@ class _CustomizeState extends State<CustomizePage> {
|
||||
),
|
||||
),
|
||||
|
||||
Select<SupportedApp?>(
|
||||
constraints: BoxConstraints(minWidth: 300),
|
||||
value: core.actionHandler.supportedApp,
|
||||
popup: SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
..._getAllApps().map(
|
||||
(a) => SelectItemButton(
|
||||
value: a,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minWidth: 300),
|
||||
child: Select<SupportedApp?>(
|
||||
value: core.actionHandler.supportedApp,
|
||||
popup: SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
Expanded(child: Text(a.name)),
|
||||
if (a is CustomApp)
|
||||
BetaPill(text: 'CUSTOM')
|
||||
else if (a.supportsOpenBikeProtocol)
|
||||
Icon(Icons.star, size: 16),
|
||||
..._getAllApps().map(
|
||||
(a) => SelectItemButton(
|
||||
value: a,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: Text(a.name)),
|
||||
if (a is CustomApp)
|
||||
BetaPill(text: 'CUSTOM')
|
||||
else if (a.supportsOpenBikeProtocol)
|
||||
Icon(Icons.star, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SelectItemButton(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(Icons.add, color: Theme.of(context).colorScheme.mutedForeground),
|
||||
Expanded(child: Text(context.i18n.createNewKeymap).normal.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SelectItemButton(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
).call,
|
||||
itemBuilder: (c, app) => Row(
|
||||
spacing: 8,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Icon(Icons.add, color: Theme.of(context).colorScheme.mutedForeground),
|
||||
Expanded(child: Text(context.i18n.createNewKeymap).normal.muted),
|
||||
Expanded(child: Text(screenshotMode ? 'Trainer app' : app!.name)),
|
||||
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
placeholder: Text(context.i18n.selectKeymap),
|
||||
|
||||
onChanged: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
} else if (app.name == 'New') {
|
||||
final profileName = await KeymapManager().showNewProfileDialog(context);
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
final customApp = CustomApp(profileName: profileName);
|
||||
core.actionHandler.init(customApp);
|
||||
await core.settings.setKeyMap(customApp);
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
core.actionHandler.init(app);
|
||||
await core.settings.setKeyMap(app);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).call,
|
||||
itemBuilder: (c, app) => Row(
|
||||
spacing: 8,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: Text(screenshotMode ? 'Trainer app' : app!.name)),
|
||||
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
placeholder: Text(context.i18n.selectKeymap),
|
||||
|
||||
onChanged: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
} else if (app.name == 'New') {
|
||||
final profileName = await KeymapManager().showNewProfileDialog(context);
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
final customApp = CustomApp(profileName: profileName);
|
||||
core.actionHandler.init(customApp);
|
||||
await core.settings.setKeyMap(customApp);
|
||||
|
||||
KeymapManager().getManageProfileDialog(
|
||||
context,
|
||||
core.actionHandler.supportedApp is CustomApp ? core.actionHandler.supportedApp?.name : null,
|
||||
onDone: () {
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
core.actionHandler.init(app);
|
||||
await core.settings.setKeyMap(app);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
KeymapManager().getManageProfileDialog(
|
||||
context,
|
||||
core.actionHandler.supportedApp is CustomApp ? core.actionHandler.supportedApp?.name : null,
|
||||
onDone: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (core.actionHandler.supportedApp is! CustomApp)
|
||||
Text(
|
||||
context.i18n.customizeKeymapHint,
|
||||
|
||||
@@ -2,25 +2,29 @@ import 'dart:async';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/button_simulator.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/iap_status_widget.dart';
|
||||
import 'package:bike_control/widgets/ignored_devices_dialog.dart';
|
||||
import 'package:bike_control/widgets/scan.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../bluetooth/devices/base_device.dart';
|
||||
|
||||
class DevicePage extends StatefulWidget {
|
||||
final bool isMobile;
|
||||
final VoidCallback onUpdate;
|
||||
const DevicePage({super.key, required this.onUpdate});
|
||||
const DevicePage({super.key, required this.onUpdate, required this.isMobile});
|
||||
|
||||
@override
|
||||
State<DevicePage> createState() => _DevicePageState();
|
||||
}
|
||||
|
||||
class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
class _DevicePageState extends State<DevicePage> {
|
||||
late StreamSubscription<BaseDevice> _connectionStateSubscription;
|
||||
|
||||
@override
|
||||
@@ -43,10 +47,9 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
return Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
primary: true,
|
||||
padding: EdgeInsets.all(16),
|
||||
padding: EdgeInsets.only(bottom: widget.isMobile ? 166 : 16, left: 16, right: 16, top: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: IAPManager.instance.isPurchased,
|
||||
@@ -62,12 +65,14 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
// leave it in for the extra scanning options
|
||||
ScanWidget(),
|
||||
|
||||
Gap(12),
|
||||
if (core.connection.controllerDevices.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(text: context.i18n.connectedControllers),
|
||||
),
|
||||
|
||||
Gap(12),
|
||||
...core.connection.controllerDevices.map(
|
||||
(device) => Card(
|
||||
filled: true,
|
||||
@@ -78,6 +83,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
|
||||
Gap(12),
|
||||
if (core.connection.accessories.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
@@ -94,6 +100,75 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
],
|
||||
|
||||
Gap(12),
|
||||
if (!screenshotMode)
|
||||
Column(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
launchUrlString(
|
||||
'https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-devices',
|
||||
);
|
||||
},
|
||||
leading: Icon(Icons.gamepad_outlined),
|
||||
child: Text(context.i18n.showSupportedControllers),
|
||||
),
|
||||
if (core.settings.getIgnoredDevices().isNotEmpty)
|
||||
OutlineButton(
|
||||
leading: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
margin: EdgeInsets.only(right: 4),
|
||||
child: Text(
|
||||
core.settings.getIgnoredDevices().length.toString(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => IgnoredDevicesDialog(),
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(context.i18n.manageIgnoredDevices),
|
||||
),
|
||||
|
||||
if (core.connection.controllerDevices.isEmpty)
|
||||
PrimaryButton(
|
||||
leading: Icon(Icons.computer_outlined),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => ButtonSimulator(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: "${AppLocalizations.of(context).noControllerUseCompanionMode.split("?").first}?\n",
|
||||
),
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context).noControllerUseCompanionMode.split("? ").last,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.muted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Gap(12),
|
||||
SizedBox(),
|
||||
if (core.connection.controllerDevices.isNotEmpty)
|
||||
Row(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart' show BackButton;
|
||||
import 'package:bike_control/widgets/ui/gradient_text.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_md/flutter_md.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class MarkdownPage extends StatefulWidget {
|
||||
@@ -40,51 +38,36 @@ class _ChangelogPageState extends State<MarkdownPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
leading: [
|
||||
BackButton(),
|
||||
],
|
||||
title: Text(
|
||||
widget.assetPath
|
||||
.replaceAll('.md', '')
|
||||
.split('_')
|
||||
.joinToString(separator: ' ', transform: (s) => s.toLowerCase().capitalize()),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _groups == null
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Accordion(
|
||||
items: _groups!
|
||||
.map(
|
||||
(group) => AccordionItem(
|
||||
trigger: AccordionTrigger(child: ColoredTitle(text: group.title)),
|
||||
content: MarkdownWidget(
|
||||
markdown: group.markdown,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Theme.of(context).colorScheme.brightness == Brightness.dark
|
||||
? Colors.white.withAlpha(255 * 70)
|
||||
: Colors.black.withAlpha(87 * 255),
|
||||
),
|
||||
onLinkTap: (title, url) {
|
||||
launchUrlString(url);
|
||||
},
|
||||
return _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _groups == null
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Accordion(
|
||||
items: _groups!
|
||||
.map(
|
||||
(group) => AccordionItem(
|
||||
trigger: AccordionTrigger(child: GradientText(group.title).bold),
|
||||
content: MarkdownWidget(
|
||||
markdown: group.markdown,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Theme.of(context).colorScheme.brightness == Brightness.dark
|
||||
? Colors.white.withAlpha(255 * 70)
|
||||
: Colors.black.withAlpha(87 * 255),
|
||||
),
|
||||
onLinkTap: (title, url) {
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
void _parseMarkdown(String md) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:bike_control/widgets/logviewer.dart';
|
||||
import 'package:bike_control/widgets/menu.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:bike_control/widgets/ui/help_button.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -51,13 +52,19 @@ class _NavigationState extends State<Navigation> {
|
||||
bool _isMobile = false;
|
||||
late BCPage _selectedPage;
|
||||
|
||||
final Map<BCPage, Key> _pageKeys = {
|
||||
BCPage.devices: Key('devices_page'),
|
||||
BCPage.trainer: Key('trainer_page'),
|
||||
BCPage.customization: Key('customization_page'),
|
||||
BCPage.logs: Key('logs_page'),
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_selectedPage = widget.page;
|
||||
|
||||
core.connection.initialize();
|
||||
core.logic.startEnabledConnectionMethod();
|
||||
|
||||
core.connection.actionStream.listen((_) {
|
||||
@@ -127,27 +134,43 @@ class _NavigationState extends State<Navigation> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
padding:
|
||||
const EdgeInsets.only(top: 12, bottom: 8, left: 12, right: 12) *
|
||||
(screenshotMode ? 2 : Theme.of(context).scaling),
|
||||
title: AppTitle(),
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
trailing: buildMenuButtons(
|
||||
context,
|
||||
_selectedPage,
|
||||
_isMobile
|
||||
? () {
|
||||
setState(() {
|
||||
_selectedPage = BCPage.logs;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
AppBar(
|
||||
padding:
|
||||
const EdgeInsets.only(top: 12, bottom: 8, left: 12, right: 12) *
|
||||
(screenshotMode ? 2 : Theme.of(context).scaling),
|
||||
title: AppTitle(),
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
trailing: buildMenuButtons(
|
||||
context,
|
||||
_selectedPage,
|
||||
_isMobile
|
||||
? () {
|
||||
setState(() {
|
||||
_selectedPage = BCPage.logs;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (!_isMobile)
|
||||
Container(
|
||||
alignment: Alignment.topCenter,
|
||||
child: HelpButton(isMobile: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
Divider(),
|
||||
],
|
||||
footers: _isMobile ? [Divider(), _buildNavigationBar()] : [],
|
||||
footers: _isMobile
|
||||
? [
|
||||
if (_isMobile) Center(child: HelpButton(isMobile: true)),
|
||||
Divider(),
|
||||
_buildNavigationBar(),
|
||||
]
|
||||
: [],
|
||||
floatingFooter: true,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -156,19 +179,25 @@ class _NavigationState extends State<Navigation> {
|
||||
VerticalDivider(),
|
||||
],
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 200),
|
||||
child: switch (_selectedPage) {
|
||||
BCPage.devices => DevicePage(
|
||||
child: AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 200),
|
||||
child: switch (_selectedPage) {
|
||||
BCPage.devices => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
key: _pageKeys[BCPage.devices],
|
||||
child: DevicePage(
|
||||
isMobile: _isMobile,
|
||||
onUpdate: () {
|
||||
setState(() {
|
||||
_selectedPage = BCPage.trainer;
|
||||
});
|
||||
},
|
||||
),
|
||||
BCPage.trainer => TrainerPage(
|
||||
),
|
||||
BCPage.trainer => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
key: _pageKeys[BCPage.trainer],
|
||||
child: TrainerPage(
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
},
|
||||
@@ -177,11 +206,21 @@ class _NavigationState extends State<Navigation> {
|
||||
_selectedPage = BCPage.customization;
|
||||
});
|
||||
},
|
||||
isMobile: _isMobile,
|
||||
),
|
||||
BCPage.customization => CustomizePage(),
|
||||
BCPage.logs => LogViewer(),
|
||||
},
|
||||
),
|
||||
),
|
||||
BCPage.customization => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
key: _pageKeys[BCPage.customization],
|
||||
child: CustomizePage(isMobile: _isMobile),
|
||||
),
|
||||
BCPage.logs => Padding(
|
||||
padding: EdgeInsets.only(bottom: _isMobile ? 146 : 16, left: 16, right: 16, top: 16),
|
||||
child: LogViewer(
|
||||
key: _pageKeys[BCPage.logs],
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
331
lib/pages/onboarding.dart
Normal file
331
lib/pages/onboarding.dart
Normal file
@@ -0,0 +1,331 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/scan.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:bike_control/widgets/ui/help_button.dart';
|
||||
import 'package:bike_control/widgets/ui/permissions_list.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../utils/i18n_extension.dart';
|
||||
import '../widgets/ui/colored_title.dart';
|
||||
import 'configuration.dart';
|
||||
|
||||
class OnboardingPage extends StatefulWidget {
|
||||
final VoidCallback onComplete;
|
||||
const OnboardingPage({super.key, required this.onComplete});
|
||||
|
||||
@override
|
||||
State<OnboardingPage> createState() => _OnboardingPageState();
|
||||
}
|
||||
|
||||
enum _OnboardingStep {
|
||||
permissions,
|
||||
connect,
|
||||
trainer,
|
||||
finish,
|
||||
}
|
||||
|
||||
class _OnboardingPageState extends State<OnboardingPage> {
|
||||
var _currentStep = _OnboardingStep.permissions;
|
||||
|
||||
bool _isMobile = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
_isMobile = MediaQuery.sizeOf(context).width < 600;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
loadingProgress: _OnboardingStep.values.indexOf(_currentStep) / (_OnboardingStep.values.length - 1),
|
||||
headers: [
|
||||
AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryForeground,
|
||||
leading: [
|
||||
Image.asset('icon.png', height: 40),
|
||||
SizedBox(width: 10),
|
||||
AppTitle(),
|
||||
],
|
||||
trailing: [
|
||||
Button(
|
||||
style: ButtonStyle.outline(size: ButtonSize.small),
|
||||
child: Text(AppLocalizations.of(context).skip),
|
||||
onPressed: () {
|
||||
core.settings.setShowOnboarding(false);
|
||||
widget.onComplete();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Divider(),
|
||||
],
|
||||
floatingFooter: true,
|
||||
footers: [
|
||||
Center(
|
||||
child: HelpButton(
|
||||
isMobile: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Center(
|
||||
child: Container(
|
||||
alignment: Alignment.topCenter,
|
||||
constraints: !_isMobile ? BoxConstraints(maxWidth: 500) : null,
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(top: !_isMobile ? 42 : 22.0, bottom: !_isMobile ? 42 : 68.0, left: 16, right: 16),
|
||||
child: AnimatedSwitcher(
|
||||
duration: Duration(milliseconds: 600),
|
||||
child: switch (_currentStep) {
|
||||
_OnboardingStep.permissions => _PermissionsOnboardingStep(
|
||||
onComplete: () {
|
||||
setState(() {
|
||||
_currentStep = _OnboardingStep.connect;
|
||||
});
|
||||
},
|
||||
),
|
||||
_OnboardingStep.connect => _ConnectOnboardingStep(
|
||||
onComplete: () {
|
||||
setState(() {
|
||||
_currentStep = _OnboardingStep.trainer;
|
||||
});
|
||||
},
|
||||
),
|
||||
_OnboardingStep.trainer => _TrainerOnboardingStep(
|
||||
onComplete: () {
|
||||
setState(() {
|
||||
_currentStep = _OnboardingStep.finish;
|
||||
});
|
||||
},
|
||||
),
|
||||
_OnboardingStep.finish => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 12,
|
||||
children: [
|
||||
SizedBox(height: 30),
|
||||
Icon(Icons.check_circle, size: 58, color: Colors.green),
|
||||
ColoredTitle(text: AppLocalizations.of(context).setupComplete),
|
||||
Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).asAFinalStepYoullChooseHowToConnectTo(core.settings.getTrainerApp()?.name ?? 'your trainer'),
|
||||
textAlign: TextAlign.center,
|
||||
).small.muted,
|
||||
|
||||
SizedBox(height: 30),
|
||||
PrimaryButton(
|
||||
leading: Icon(Icons.check),
|
||||
onPressed: () {
|
||||
core.settings.setShowOnboarding(false);
|
||||
widget.onComplete();
|
||||
},
|
||||
child: Text(context.i18n.continueAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PermissionsOnboardingStep extends StatefulWidget {
|
||||
final VoidCallback onComplete;
|
||||
const _PermissionsOnboardingStep({super.key, required this.onComplete});
|
||||
|
||||
@override
|
||||
State<_PermissionsOnboardingStep> createState() => _PermissionsOnboardingStepState();
|
||||
}
|
||||
|
||||
class _PermissionsOnboardingStepState extends State<_PermissionsOnboardingStep> {
|
||||
void _checkRequirements() {
|
||||
core.permissions.getScanRequirements().then((permissions) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_needsPermissions = permissions;
|
||||
});
|
||||
if (permissions.isEmpty) {
|
||||
widget.onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
List<PlatformRequirement>? _needsPermissions;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkRequirements();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: 8),
|
||||
Text(AppLocalizations.of(context).letsGetYouSetUp).h3,
|
||||
if (_needsPermissions != null && _needsPermissions!.isNotEmpty)
|
||||
PermissionList(
|
||||
requirements: _needsPermissions!,
|
||||
onDone: () {
|
||||
widget.onComplete();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConnectOnboardingStep extends StatefulWidget {
|
||||
final VoidCallback onComplete;
|
||||
const _ConnectOnboardingStep({super.key, required this.onComplete});
|
||||
|
||||
@override
|
||||
State<_ConnectOnboardingStep> createState() => _ConnectOnboardingStepState();
|
||||
}
|
||||
|
||||
class _ConnectOnboardingStepState extends State<_ConnectOnboardingStep> {
|
||||
late StreamSubscription<BaseDevice> _connectionStateSubscription;
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_actionSubscription = core.connection.actionStream.listen((data) async {
|
||||
setState(() {});
|
||||
if (data is ButtonNotification) {
|
||||
widget.onComplete();
|
||||
}
|
||||
});
|
||||
_connectionStateSubscription = core.connection.connectionStream.listen((state) async {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectionStateSubscription.cancel();
|
||||
_actionSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
ColoredTitle(text: context.i18n.connectControllers),
|
||||
if (core.connection.controllerDevices.isEmpty) ...[
|
||||
ScanWidget(),
|
||||
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-devices');
|
||||
},
|
||||
leading: Icon(Icons.gamepad_outlined),
|
||||
child: Text(context.i18n.showSupportedControllers),
|
||||
),
|
||||
PrimaryButton(
|
||||
leading: Icon(Icons.computer_outlined),
|
||||
onPressed: () {
|
||||
widget.onComplete();
|
||||
},
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: "${AppLocalizations.of(context).noControllerUseCompanionMode.split("?").first}?\n"),
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context).noControllerUseCompanionMode.split("? ").last,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.muted, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
if (core.connection.controllerDevices.any((d) => d.isConnected && d is ZwiftDevice))
|
||||
RepeatedAnimationBuilder<double>(
|
||||
duration: Duration(seconds: 1),
|
||||
start: 0.5,
|
||||
end: 1.0,
|
||||
curve: Curves.easeInOut,
|
||||
mode: LoopingMode.pingPong,
|
||||
builder: (context, value, child) {
|
||||
return Opacity(
|
||||
opacity: value,
|
||||
child: Text(
|
||||
AppLocalizations.of(context).controllerConnectedClickButton,
|
||||
).small,
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(),
|
||||
...core.connection.controllerDevices.map(
|
||||
(device) => device.showInformation(context),
|
||||
),
|
||||
if (core.connection.controllerDevices.any((d) => d.isConnected))
|
||||
PrimaryButton(
|
||||
leading: Icon(Icons.check),
|
||||
onPressed: () {
|
||||
widget.onComplete();
|
||||
},
|
||||
child: Text(context.i18n.continueAction),
|
||||
),
|
||||
SizedBox(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrainerOnboardingStep extends StatefulWidget {
|
||||
final VoidCallback onComplete;
|
||||
const _TrainerOnboardingStep({super.key, required this.onComplete});
|
||||
|
||||
@override
|
||||
State<_TrainerOnboardingStep> createState() => _TrainerOnboardingStepState();
|
||||
}
|
||||
|
||||
class _TrainerOnboardingStepState extends State<_TrainerOnboardingStep> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 12,
|
||||
children: [
|
||||
SizedBox(),
|
||||
ConfigurationPage(
|
||||
onboardingMode: true,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (core.settings.getTrainerApp() != null) SizedBox(height: 20),
|
||||
if (core.settings.getTrainerApp() != null)
|
||||
PrimaryButton(
|
||||
leading: Icon(Icons.check),
|
||||
onPressed: () {
|
||||
widget.onComplete();
|
||||
},
|
||||
child: Text(context.i18n.continueAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import 'package:bike_control/pages/navigation.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/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/widgets/apps/local_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/mywhoosh_link_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart';
|
||||
@@ -18,17 +17,20 @@ import 'package:bike_control/widgets/iap_status_widget.dart';
|
||||
import 'package:bike_control/widgets/pair_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import '../utils/keymap/apps/supported_app.dart';
|
||||
|
||||
class TrainerPage extends StatefulWidget {
|
||||
final bool isMobile;
|
||||
final VoidCallback onUpdate;
|
||||
final VoidCallback goToNextPage;
|
||||
const TrainerPage({super.key, required this.onUpdate, required this.goToNextPage});
|
||||
const TrainerPage({super.key, required this.onUpdate, required this.goToNextPage, required this.isMobile});
|
||||
|
||||
@override
|
||||
State<TrainerPage> createState() => _TrainerPageState();
|
||||
@@ -89,106 +91,170 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showLocalAsOther =
|
||||
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showLocalControl;
|
||||
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) &&
|
||||
core.logic.showLocalControl &&
|
||||
!core.settings.getLocalEnabled();
|
||||
final showWhooshLinkAsOther =
|
||||
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showMyWhooshLink;
|
||||
|
||||
final isMobile = MediaQuery.sizeOf(context).width < 800;
|
||||
final recommendedTiles = [
|
||||
if (core.logic.showObpMdnsEmulator) OpenBikeControlMdnsTile(),
|
||||
if (core.logic.showObpBluetoothEmulator) OpenBikeControlBluetoothTile(),
|
||||
|
||||
if (core.logic.showZwiftMsdnEmulator)
|
||||
ZwiftMdnsTile(
|
||||
onUpdate: () {
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (core.logic.showZwiftBleEmulator)
|
||||
ZwiftTile(
|
||||
onUpdate: () {
|
||||
if (mounted) {
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(),
|
||||
if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(),
|
||||
];
|
||||
|
||||
final otherTiles = [
|
||||
if (core.logic.showRemote) RemotePairingWidget(),
|
||||
if (showLocalAsOther) LocalTile(),
|
||||
if (showWhooshLinkAsOther) MyWhooshLinkTile(),
|
||||
];
|
||||
|
||||
return Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.all(16),
|
||||
padding: EdgeInsets.only(bottom: widget.isMobile ? 166 : 16, left: 16, right: 16, top: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: IAPManager.instance.isPurchased,
|
||||
builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: true),
|
||||
),
|
||||
ConfigurationPage(
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
if (_scrollController.position.pixels != _scrollController.position.maxScrollExtent &&
|
||||
core.settings.getLastTarget() == Target.otherDevice) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.offset + 300,
|
||||
duration: Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Accordion(
|
||||
items: [
|
||||
AccordionItem(
|
||||
trigger: AccordionTrigger(
|
||||
child: IgnorePointer(
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Select<SupportedApp>(
|
||||
itemBuilder: (c, app) => Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Expanded(child: Text(screenshotMode ? 'Trainer app' : app.name)),
|
||||
if (app.supportsOpenBikeProtocol) Icon(Icons.star),
|
||||
],
|
||||
),
|
||||
popup: SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: SupportedApp.supportedApps.map((app) {
|
||||
return SelectItemButton(
|
||||
value: app,
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(app.name),
|
||||
if (app.supportsOpenBikeProtocol) Icon(Icons.star),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
).call,
|
||||
placeholder: Text(context.i18n.selectTrainerAppPlaceholder),
|
||||
value: core.settings.getTrainerApp(),
|
||||
onChanged: (selectedApp) async {},
|
||||
),
|
||||
),
|
||||
if (core.settings.getLastTarget() != null) ...[
|
||||
if (!widget.isMobile) Icon(core.settings.getLastTarget()!.icon),
|
||||
Text(core.settings.getLastTarget()!.getTitle(context)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
content: ConfigurationPage(
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (core.settings.getTrainerApp() != null) ...[
|
||||
SizedBox(height: 8),
|
||||
if (core.logic.hasRecommendedConnectionMethods)
|
||||
Gap(22),
|
||||
if (recommendedTiles.isNotEmpty) ...[
|
||||
ColoredTitle(text: context.i18n.recommendedConnectionMethods),
|
||||
Gap(12),
|
||||
],
|
||||
|
||||
if (core.logic.showObpMdnsEmulator) OpenBikeControlMdnsTile(),
|
||||
if (core.logic.showObpBluetoothEmulator) OpenBikeControlBluetoothTile(),
|
||||
|
||||
if (core.logic.showZwiftMsdnEmulator)
|
||||
ZwiftMdnsTile(
|
||||
onUpdate: () {
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (core.logic.showZwiftBleEmulator)
|
||||
ZwiftTile(
|
||||
onUpdate: () {
|
||||
if (mounted) {
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(),
|
||||
if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(),
|
||||
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: '${context.i18n.needHelpClickHelp} '),
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Icon(Icons.help_outline),
|
||||
),
|
||||
for (final grouped in recommendedTiles.chunked(widget.isMobile ? 1 : 2)) ...[
|
||||
IntrinsicHeight(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: grouped.map((tile) => Expanded(child: tile)).toList(),
|
||||
),
|
||||
TextSpan(text: ' ${context.i18n.needHelpDontHesitate}'),
|
||||
],
|
||||
),
|
||||
).small.muted,
|
||||
if (core.logic.showRemote || showLocalAsOther || showWhooshLinkAsOther) ...[
|
||||
SizedBox(height: 16),
|
||||
Accordion(
|
||||
items: [
|
||||
AccordionItem(
|
||||
trigger: AccordionTrigger(child: ColoredTitle(text: context.i18n.otherConnectionMethods)),
|
||||
content: Column(
|
||||
children: [
|
||||
if (core.logic.showRemote) RemotePairingWidget(),
|
||||
if (showLocalAsOther) LocalTile(),
|
||||
if (showWhooshLinkAsOther) MyWhooshLinkTile(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
Gap(12),
|
||||
if (otherTiles.isNotEmpty) ...[
|
||||
SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Accordion(
|
||||
items: [
|
||||
AccordionItem(
|
||||
trigger: AccordionTrigger(child: ColoredTitle(text: context.i18n.otherConnectionMethods)),
|
||||
content: Column(
|
||||
children: [
|
||||
for (final grouped in otherTiles.chunked(widget.isMobile ? 1 : 2)) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: grouped.map((tile) => Expanded(child: tile)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
Gap(12),
|
||||
|
||||
SizedBox(height: 4),
|
||||
Flex(
|
||||
direction: isMobile ? Axis.vertical : Axis.horizontal,
|
||||
direction: widget.isMobile ? Axis.vertical : Axis.horizontal,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -31,7 +30,9 @@ class AndroidActions extends BaseActions {
|
||||
final hidDevice = HidDevice(keyPressed.source);
|
||||
final button = hidDevice.getOrAddButton(keyPressed.hidKey, () => ControllerButton(keyPressed.hidKey));
|
||||
|
||||
var availableDevice = core.connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
|
||||
var availableDevice = core.connection.controllerDevices.firstOrNullWhere(
|
||||
(e) => e.toString() == hidDevice.toString(),
|
||||
);
|
||||
if (availableDevice == null) {
|
||||
core.connection.addDevices([hidDevice]);
|
||||
availableDevice = hidDevice;
|
||||
@@ -83,7 +84,7 @@ class AndroidActions extends BaseActions {
|
||||
: "up"}",
|
||||
);
|
||||
}
|
||||
return NotHandled('No action assigned for ${button.toString().splitByUpperCase()}');
|
||||
return NotHandled('No action assigned for ${button.name}');
|
||||
}
|
||||
|
||||
void ignoreHidDevices() {
|
||||
|
||||
@@ -176,11 +176,11 @@ abstract class BaseActions {
|
||||
class StubActions extends BaseActions {
|
||||
StubActions({super.supportedModes = const []});
|
||||
|
||||
final List<ControllerButton> performedActions = [];
|
||||
final List<(ControllerButton button, bool isDown, bool isUp)> performedActions = [];
|
||||
|
||||
@override
|
||||
Future<ActionResult> performAction(ControllerButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
performedActions.add(button);
|
||||
performedActions.add((button, isKeyDown, isKeyUp));
|
||||
return Future.value(Ignored('${button.name.splitByUpperCase()} clicked'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class DesktopActions extends BaseActions {
|
||||
DesktopActions({super.supportedModes = const [SupportedMode.keyboard, SupportedMode.touch, SupportedMode.media]});
|
||||
@@ -20,12 +21,41 @@ class DesktopActions extends BaseActions {
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(button)!;
|
||||
|
||||
if (core.settings.getLocalEnabled()) {
|
||||
// Handle media keys
|
||||
if (keyPair.isSpecialKey) {
|
||||
try {
|
||||
await keyPressSimulator.simulateMediaKey(keyPair.physicalKey!);
|
||||
// Increment command count after successful execution
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
return Success('Media key pressed: $keyPair');
|
||||
} catch (e) {
|
||||
return Error('Failed to simulate media key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (keyPair.physicalKey != null) {
|
||||
// Increment command count after successful execution
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
if (keyPair.logicalKey != null && navigatorKey.currentContext?.mounted == true) {
|
||||
final label = keyPair.logicalKey!.keyLabel;
|
||||
final keyName = label.isNotEmpty ? label : keyPair.logicalKey!.debugName ?? 'Key';
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
|
||||
location: ToastLocation.bottomLeft,
|
||||
title:
|
||||
'${isKeyDown
|
||||
? "↓"
|
||||
: isKeyUp
|
||||
? "↑"
|
||||
: "•"} $keyName',
|
||||
);
|
||||
}
|
||||
|
||||
if (isKeyDown && isKeyUp) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
|
||||
|
||||
return Success('Key clicked: $keyPair');
|
||||
} else if (isKeyDown) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
|
||||
|
||||
@@ -77,6 +77,7 @@ class Permissions {
|
||||
} else if (Platform.isWindows) {
|
||||
list = [
|
||||
BluetoothTurnedOn(),
|
||||
NotificationRequirement(),
|
||||
];
|
||||
} else if (Platform.isAndroid) {
|
||||
final deviceInfoPlugin = DeviceInfoPlugin();
|
||||
@@ -411,7 +412,9 @@ class MediaKeyHandler {
|
||||
() => ControllerButton(keyPressed),
|
||||
);
|
||||
|
||||
var availableDevice = core.connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
|
||||
var availableDevice = core.connection.controllerDevices.firstOrNullWhere(
|
||||
(e) => e.toString() == hidDevice.toString(),
|
||||
);
|
||||
if (availableDevice == null) {
|
||||
core.connection.addDevices([hidDevice]);
|
||||
availableDevice = hidDevice;
|
||||
|
||||
@@ -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();
|
||||
@@ -126,10 +157,12 @@ class IAPManager {
|
||||
/// Get a status message for the user
|
||||
String getStatusMessage() {
|
||||
/// Get a status message for the user
|
||||
if (IAPManager.instance.isPurchased.value) {
|
||||
if (kIsWeb) {
|
||||
return "Web";
|
||||
} else 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 +171,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,20 +183,40 @@ 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(String purchaseId) async {
|
||||
if (_revenueCatService != null) {
|
||||
await _revenueCatService!.redeem(purchaseId);
|
||||
} else if (_iapService != null) {
|
||||
await _iapService!.redeem();
|
||||
}
|
||||
}
|
||||
|
||||
void setAttributes() {
|
||||
_revenueCatService?.setAttributes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ 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/utils/iap/iap_manager.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:ios_receipt/ios_receipt.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
@@ -22,6 +23,8 @@ class IAPService {
|
||||
static const String _purchaseStatusKey = 'iap_purchase_status';
|
||||
static const String _dailyCommandCountKey = 'iap_daily_command_count';
|
||||
static const String _lastCommandDateKey = 'iap_last_command_date';
|
||||
static const String _lastPurchaseCheckKey = 'iap_last_purchase_check';
|
||||
static const String _hasPurchasedKey = 'iap_has_purchased';
|
||||
|
||||
final InAppPurchase _inAppPurchase = InAppPurchase.instance;
|
||||
final FlutterSecureStorage _prefs;
|
||||
@@ -71,6 +74,8 @@ class IAPService {
|
||||
);
|
||||
|
||||
_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';
|
||||
@@ -99,11 +104,25 @@ class IAPService {
|
||||
Future<void> _checkExistingPurchase() async {
|
||||
// First check if we have a stored purchase status
|
||||
final storedStatus = await _prefs.read(key: _purchaseStatusKey);
|
||||
final lastPurchaseCheck = await _prefs.read(key: _lastPurchaseCheckKey);
|
||||
final hasPurchased = await _prefs.read(key: _hasPurchasedKey);
|
||||
|
||||
String todayDate = DateFormat('yyyy-MM-dd').format(DateTime.now());
|
||||
|
||||
if (storedStatus == "true") {
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
if (Platform.isAndroid) {
|
||||
if (lastPurchaseCheck == todayDate || hasPurchased == null) {
|
||||
// hasPurchased means it was redeemed manually, so we skip the daily check
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
}
|
||||
} else {
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await _prefs.write(key: _lastPurchaseCheckKey, value: todayDate);
|
||||
|
||||
// Platform-specific checks for existing paid app purchases
|
||||
if (Platform.isIOS || Platform.isMacOS) {
|
||||
// On iOS/macOS, check if the app was previously purchased (has a receipt)
|
||||
@@ -125,14 +144,14 @@ class IAPService {
|
||||
final receiptContent = await IosReceipt.getAppleReceipt();
|
||||
if (receiptContent != null) {
|
||||
debugPrint('Existing Apple user detected - validating receipt $receiptContent');
|
||||
final sharedSecret =
|
||||
Platform.environment['VERIFYING_SHARED_SECRET'] ?? String.fromEnvironment("VERIFYING_SHARED_SECRET");
|
||||
var sharedSecret =
|
||||
Platform.environment['VERIFYING_SHARED_SECRET'] ?? const String.fromEnvironment("VERIFYING_SHARED_SECRET");
|
||||
|
||||
if (sharedSecret.isEmpty) {
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Shared Secret is empty'));
|
||||
}
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Using shared secret: ${sharedSecret.characters.take(15).join()}'),
|
||||
LogNotification('Using shared secret: ${sharedSecret.characters.take(10).join()}'),
|
||||
);
|
||||
await validateReceipt(
|
||||
base64Receipt: receiptContent,
|
||||
@@ -142,6 +161,7 @@ class IAPService {
|
||||
debugPrint('No Apple receipt found');
|
||||
}
|
||||
} catch (e) {
|
||||
core.connection.signalNotification(LogNotification('There was an error checking Apple receipt: ${e.toString()}'));
|
||||
debugPrint('Error checking Apple receipt: $e');
|
||||
}
|
||||
}
|
||||
@@ -195,13 +215,18 @@ class IAPService {
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Apple receipt validated for version: $purchasedVersion'),
|
||||
);
|
||||
IAPManager.instance.isPurchased.value = Version.parse(purchasedVersion) < Version(4, 2, 0);
|
||||
|
||||
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 0;
|
||||
|
||||
IAPManager.instance.isPurchased.value = purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58);
|
||||
if (IAPManager.instance.isPurchased.value) {
|
||||
debugPrint('Apple receipt validation successful - granting full access');
|
||||
await _prefs.write(key: _purchaseStatusKey, value: "true");
|
||||
} else {
|
||||
debugPrint('Apple receipt validation failed - no full access');
|
||||
}
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
@@ -215,6 +240,7 @@ class IAPService {
|
||||
// while the app is still paid. Only users who downloaded the paid version will have
|
||||
// a last_seen_version. After changing the app to free, new users won't have this set.
|
||||
final lastSeenVersion = core.settings.getLastSeenVersion();
|
||||
core.connection.signalNotification(LogNotification('Android last seen version: $lastSeenVersion'));
|
||||
if (lastSeenVersion != null && lastSeenVersion.isNotEmpty) {
|
||||
Version lastVersion = Version.parse(lastSeenVersion);
|
||||
// If they had a previous version, they're an existing paid user
|
||||
@@ -248,10 +274,12 @@ class IAPService {
|
||||
Future<void> _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) async {
|
||||
for (final purchase in purchaseDetailsList) {
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Purchase found: ${purchase.productID} - ${purchase.status}'),
|
||||
LogNotification('Purchase found: ${purchase.purchaseID} ${purchase.productID} - ${purchase.status}'),
|
||||
);
|
||||
if (purchase.status == PurchaseStatus.purchased || purchase.status == PurchaseStatus.restored) {
|
||||
IAPManager.instance.isPurchased.value = !kDebugMode;
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
|
||||
await _prefs.write(key: _hasPurchasedKey, value: "true");
|
||||
await _prefs.write(key: _purchaseStatusKey, value: IAPManager.instance.isPurchased.value.toString());
|
||||
debugPrint('Purchase successful or restored');
|
||||
}
|
||||
@@ -273,6 +301,9 @@ class IAPService {
|
||||
final available = await _inAppPurchase.isAvailable();
|
||||
if (!available) {
|
||||
debugPrint('IAP not available');
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'IAP is not available on this platform.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,11 +313,25 @@ class IAPService {
|
||||
final response = await _inAppPurchase.queryProductDetails({productId});
|
||||
if (response.error != null) {
|
||||
debugPrint('Error querying products: ${response.error}');
|
||||
if (response.error!.code == 'storekit_no_response') {
|
||||
_trialStartDate = DateTime.now().toIso8601String();
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_WARNING,
|
||||
'Unlock will be available, soon! Trial days have been extended.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'IAP issue: ${response.error!.toString()}'),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.productDetails.isEmpty) {
|
||||
debugPrint('Product not found: $productId');
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_INFO, 'IAP issue: Product not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -294,6 +339,7 @@ class IAPService {
|
||||
final purchaseParam = PurchaseParam(productDetails: product);
|
||||
|
||||
await _inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam);
|
||||
await restorePurchases();
|
||||
} catch (e, s) {
|
||||
debugPrint('Error purchasing: $e');
|
||||
recordError(e, s, context: 'Error purchasing');
|
||||
@@ -386,13 +432,18 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> redeem() async {
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
await _prefs.write(key: _purchaseStatusKey, value: IAPManager.instance.isPurchased.value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
372
lib/utils/iap/revenuecat_service.dart
Normal file
372
lib/utils/iap/revenuecat_service.dart
Normal file
@@ -0,0 +1,372 @@
|
||||
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/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:dartx/dartx.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;
|
||||
|
||||
static const _isAndroidWorking = false;
|
||||
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 (Platform.isAndroid && !isPurchasedNotifier.value && !_isAndroidWorking) {
|
||||
setDailyCommandLimit(10000);
|
||||
} else 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();
|
||||
await _handleCustomerInfoUpdate(customerInfo);
|
||||
} catch (e, s) {
|
||||
debugPrint('Error checking existing purchase: $e');
|
||||
recordError(e, s, context: 'Checking existing purchase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle customer info updates from RevenueCat
|
||||
Future<bool> _handleCustomerInfoUpdate(CustomerInfo customerInfo) async {
|
||||
final hasEntitlement = customerInfo.entitlements.active.containsKey(fullVersionEntitlement);
|
||||
|
||||
final userId = await Purchases.appUserID;
|
||||
core.connection.signalNotification(LogNotification('User ID: $userId at ${customerInfo.requestDate}'));
|
||||
core.connection.signalNotification(LogNotification('Full Version entitlement: $hasEntitlement'));
|
||||
|
||||
if (!hasEntitlement) {
|
||||
// purchased before IAP migration
|
||||
if (Platform.isAndroid) {
|
||||
final storedStatus = await _prefs.read(key: _purchaseStatusKey);
|
||||
if (storedStatus == "true") {
|
||||
core.connection.signalNotification(LogNotification('Setting full version based on stored status'));
|
||||
await Purchases.setAttributes({_purchaseStatusKey: "true"});
|
||||
isPurchasedNotifier.value = true;
|
||||
}
|
||||
} else {
|
||||
final purchasedVersion = customerInfo.originalApplicationVersion;
|
||||
core.connection.signalNotification(LogNotification('Apple receipt validated for version: $purchasedVersion'));
|
||||
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 1337;
|
||||
isPurchasedNotifier.value = purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58);
|
||||
}
|
||||
} else {
|
||||
isPurchasedNotifier.value = hasEntitlement;
|
||||
}
|
||||
return isPurchasedNotifier.value;
|
||||
}
|
||||
|
||||
/// 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();
|
||||
final result = await _handleCustomerInfoUpdate(customerInfo);
|
||||
|
||||
if (result) {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(zp.LogLevel.LOGLEVEL_INFO, 'Purchase 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.isAndroid && !_isAndroidWorking) {
|
||||
buildToast(
|
||||
context,
|
||||
title: AppLocalizations.of(context).unlockingNotPossible,
|
||||
duration: Duration(seconds: 5),
|
||||
);
|
||||
setDailyCommandLimit(10000);
|
||||
} else if (Platform.isMacOS) {
|
||||
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'),
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
var errorCode = PurchasesErrorHelper.getErrorCode(e);
|
||||
if (errorCode != PurchasesErrorCode.purchaseCancelledError) {
|
||||
buildToast(context, title: e.message);
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
Purchases.invalidateCustomerInfoCache();
|
||||
await initialize();
|
||||
_checkExistingPurchase();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> redeem(String purchaseId) async {
|
||||
await Purchases.setAttributes({"purchase_id": purchaseId});
|
||||
core.connection.signalNotification(LogNotification('Redeemed purchase ID: $purchaseId'));
|
||||
Purchases.invalidateCustomerInfoCache();
|
||||
_checkExistingPurchase();
|
||||
isPurchasedNotifier.value = true;
|
||||
}
|
||||
|
||||
Future<void> setAttributes() async {
|
||||
// attributes are fully anonymous
|
||||
await Purchases.setAttributes({
|
||||
"bikecontrol_trainer": core.settings.getTrainerApp()?.name ?? '-',
|
||||
"bikecontrol_target": core.settings.getLastTarget()?.name ?? '-',
|
||||
if (core.connection.controllerDevices.isNotEmpty)
|
||||
'bikecontrol_controllers': core.connection.controllerDevices.joinToString(
|
||||
transform: (d) => d.toString(),
|
||||
separator: ',',
|
||||
),
|
||||
'bikecontrol_keymap': core.settings.getKeyMap()?.name ?? '-',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:windows_iap/windows_iap.dart';
|
||||
@@ -14,7 +17,7 @@ class WindowsIAPService {
|
||||
static const int trialDays = 7;
|
||||
static const int dailyCommandLimit = 15;
|
||||
|
||||
static const String _purchaseStatusKey = 'iap_purchase_status';
|
||||
static const String _purchaseStatusKey = 'iap_purchase_status_2';
|
||||
static const String _dailyCommandCountKey = 'iap_daily_command_count';
|
||||
static const String _lastCommandDateKey = 'iap_last_command_date';
|
||||
|
||||
@@ -51,13 +54,25 @@ class WindowsIAPService {
|
||||
Future<void> _checkExistingPurchase() async {
|
||||
// First check if we have a stored purchase status
|
||||
final storedStatus = await _prefs.read(key: _purchaseStatusKey);
|
||||
core.connection.signalNotification(LogNotification('Is purchased status: $storedStatus'));
|
||||
if (storedStatus == "true") {
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
return;
|
||||
}
|
||||
final trial = await _windowsIapPlugin.getTrialStatusAndRemainingDays();
|
||||
trialDaysRemaining = trial.remainingDays;
|
||||
if (!trial.isTrial && trial.remainingDays <= 0) {
|
||||
core.connection.signalNotification(LogNotification('Trial status: $trial'));
|
||||
final trialEndDate = trial.remainingDays;
|
||||
if (trial.isTrial && trialEndDate.isNotEmpty && !trialEndDate.contains("?")) {
|
||||
try {
|
||||
trialDaysRemaining = DateTime.parse(trialEndDate).difference(DateTime.now()).inDays;
|
||||
} catch (e) {
|
||||
core.connection.signalNotification(LogNotification('Error parsing trial end date: $e'));
|
||||
trialDaysRemaining = 0;
|
||||
}
|
||||
} else {
|
||||
trialDaysRemaining = 0;
|
||||
}
|
||||
if (trial.isActive && !trial.isTrial && trialDaysRemaining <= 0) {
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
await _prefs.write(key: _purchaseStatusKey, value: "true");
|
||||
} else {
|
||||
@@ -71,11 +86,12 @@ class WindowsIAPService {
|
||||
try {
|
||||
final status = await _windowsIapPlugin.makePurchase(productId);
|
||||
if (status == StorePurchaseStatus.succeeded || status == StorePurchaseStatus.alreadyPurchased) {
|
||||
/*buildToast(
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: 'Purchase Successful',
|
||||
subtitle: 'Thank you for your purchase! You now have unlimited access.',
|
||||
);*/
|
||||
);
|
||||
}
|
||||
} catch (e, s) {
|
||||
recordError(e, s, context: 'Purchasing on Windows');
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:bike_control/bluetooth/devices/elite/elite_square.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
@@ -19,6 +19,7 @@ class TrainingPeaks extends SupportedApp {
|
||||
compatibleTargets: !kIsWeb && Platform.isIOS ? [Target.otherDevice] : Target.values,
|
||||
supportsZwiftEmulation: false,
|
||||
supportsOpenBikeProtocol: false,
|
||||
star: true,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
// Explicit controller-button mappings with updated touch coordinates
|
||||
|
||||
@@ -33,7 +33,11 @@ enum InGameAction {
|
||||
|
||||
// headwind
|
||||
headwindSpeed('Headwind Speed', possibleValues: [0, 25, 50, 75, 100]),
|
||||
headwindHeartRateMode('Headwind HR Mode');
|
||||
headwindHeartRateMode('Headwind HR Mode'),
|
||||
|
||||
// openbikecontrol
|
||||
up('Up', icon: RadixIcons.arrowUp),
|
||||
down('Down', icon: RadixIcons.arrowDown);
|
||||
|
||||
final String title;
|
||||
final bool isLongPress;
|
||||
|
||||
@@ -130,15 +130,18 @@ class KeyPair {
|
||||
|
||||
IconData? get icon {
|
||||
return switch (physicalKey) {
|
||||
_ when isSpecialKey && core.actionHandler.supportedModes.contains(SupportedMode.media) => switch (physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => Icons.play_arrow_outlined,
|
||||
PhysicalKeyboardKey.mediaStop => Icons.stop,
|
||||
PhysicalKeyboardKey.mediaTrackPrevious => Icons.skip_previous,
|
||||
PhysicalKeyboardKey.mediaTrackNext => Icons.skip_next,
|
||||
PhysicalKeyboardKey.audioVolumeUp => Icons.volume_up,
|
||||
PhysicalKeyboardKey.audioVolumeDown => Icons.volume_down,
|
||||
_ => Icons.keyboard,
|
||||
},
|
||||
//_ when inGameAction != null && core.logic.emulatorEnabled => Icons.link,
|
||||
_ when inGameAction != null && inGameAction!.icon != null => inGameAction!.icon,
|
||||
|
||||
PhysicalKeyboardKey.mediaPlayPause ||
|
||||
PhysicalKeyboardKey.mediaStop ||
|
||||
PhysicalKeyboardKey.mediaTrackPrevious ||
|
||||
PhysicalKeyboardKey.mediaTrackNext ||
|
||||
PhysicalKeyboardKey.audioVolumeUp ||
|
||||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
|
||||
_ when physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard) =>
|
||||
RadixIcons.keyboard,
|
||||
_
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import 'apps/custom_app.dart';
|
||||
|
||||
@@ -45,8 +45,8 @@ class KeymapManager {
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return OutlineButton(
|
||||
child: Text(context.i18n.manageProfile),
|
||||
return Button.outline(
|
||||
child: Icon(Icons.settings),
|
||||
onPressed: () => showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
|
||||
@@ -3,20 +3,26 @@ import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/accessibility_disclosure_dialog.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class AccessibilityRequirement extends PlatformRequirement {
|
||||
AccessibilityRequirement()
|
||||
: super(
|
||||
AppLocalizations.current.allowAccessibilityService,
|
||||
description: AppLocalizations.current.accessibilityDescription,
|
||||
icon: Icons.accessibility_new,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -57,7 +63,7 @@ class AccessibilityRequirement extends PlatformRequirement {
|
||||
}
|
||||
|
||||
class BluetoothScanRequirement extends PlatformRequirement {
|
||||
BluetoothScanRequirement() : super(AppLocalizations.current.allowBluetoothScan);
|
||||
BluetoothScanRequirement() : super(AppLocalizations.current.allowBluetoothScan, icon: Icons.bluetooth_searching);
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
@@ -74,7 +80,7 @@ class BluetoothScanRequirement extends PlatformRequirement {
|
||||
}
|
||||
|
||||
class LocationRequirement extends PlatformRequirement {
|
||||
LocationRequirement() : super(AppLocalizations.current.allowLocationForBluetooth);
|
||||
LocationRequirement() : super(AppLocalizations.current.allowLocationForBluetooth, icon: Icons.location_on);
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
@@ -91,7 +97,8 @@ class LocationRequirement extends PlatformRequirement {
|
||||
}
|
||||
|
||||
class BluetoothConnectRequirement extends PlatformRequirement {
|
||||
BluetoothConnectRequirement() : super(AppLocalizations.current.allowBluetoothConnections);
|
||||
BluetoothConnectRequirement()
|
||||
: super(AppLocalizations.current.allowBluetoothConnections, icon: Icons.bluetooth_connected);
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
@@ -112,29 +119,52 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
: super(
|
||||
AppLocalizations.current.allowPersistentNotification,
|
||||
description: AppLocalizations.current.notificationDescription,
|
||||
icon: Icons.notifications_active,
|
||||
);
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
if (Platform.isAndroid) {
|
||||
await core.flutterLocalNotificationsPlugin
|
||||
final result = await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
if (result == false) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: 'Enable notifications for BikeControl in Android Settings',
|
||||
);
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
await core.flutterLocalNotificationsPlugin
|
||||
final result = await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(
|
||||
alert: true,
|
||||
badge: false,
|
||||
sound: false,
|
||||
);
|
||||
core.settings.setHasAskedPermissions(true);
|
||||
if (result == false) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: 'Enable notifications for BikeControl in System Preferences → Notifications → Bike Control',
|
||||
);
|
||||
launchUrlString('x-apple.systempreferences:com.apple.preference.notifications');
|
||||
}
|
||||
} else if (Platform.isMacOS) {
|
||||
await core.flutterLocalNotificationsPlugin
|
||||
final result = await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(
|
||||
alert: true,
|
||||
badge: false,
|
||||
sound: false,
|
||||
);
|
||||
core.settings.setHasAskedPermissions(true);
|
||||
if (result == false) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: 'Enable notifications for BikeControl in System Preferences → Notifications → Bike Control',
|
||||
);
|
||||
launchUrlString('x-apple.systempreferences:com.apple.preference.notifications');
|
||||
}
|
||||
}
|
||||
await getStatus();
|
||||
return;
|
||||
@@ -153,12 +183,12 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
final permissions = await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
|
||||
?.checkPermissions();
|
||||
status = permissions?.isEnabled == true;
|
||||
status = permissions?.isEnabled == true || core.settings.hasAskedPermissions();
|
||||
} else if (Platform.isMacOS) {
|
||||
final permissions = await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>()
|
||||
?.checkPermissions();
|
||||
status = permissions?.isEnabled == true;
|
||||
status = permissions?.isEnabled == true || core.settings.hasAskedPermissions();
|
||||
} else {
|
||||
status = true;
|
||||
}
|
||||
@@ -182,6 +212,11 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
macOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
),
|
||||
windows: WindowsInitializationSettings(
|
||||
appName: 'BikeControl',
|
||||
appUserModelId: 'OpenBikeControl.BikeControl',
|
||||
guid: UUID.short(0x12).toString(),
|
||||
),
|
||||
),
|
||||
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
|
||||
onDidReceiveNotificationResponse: (n) {
|
||||
@@ -222,8 +257,14 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
'Keep Alive',
|
||||
actions: [
|
||||
AndroidNotificationAction(
|
||||
'disconnect',
|
||||
AppLocalizations.current.disconnectDevices,
|
||||
AppLocalizations.current.disconnectDevices,
|
||||
cancelNotification: true,
|
||||
showsUserInterface: false,
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
'close',
|
||||
AppLocalizations.current.close,
|
||||
cancelNotification: true,
|
||||
showsUserInterface: false,
|
||||
),
|
||||
@@ -234,9 +275,18 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
final receivePort = ReceivePort();
|
||||
IsolateNameServer.registerPortWithName(receivePort.sendPort, '_backgroundChannelKey');
|
||||
final backgroundMessagePort = receivePort.asBroadcastStream();
|
||||
backgroundMessagePort.listen((_) {
|
||||
UniversalBle.onAvailabilityChange = null;
|
||||
core.connection.reset();
|
||||
backgroundMessagePort.listen((message) {
|
||||
if (message == 'disconnect' || message == 'close') {
|
||||
UniversalBle.onAvailabilityChange = null;
|
||||
core.connection.disconnectAll();
|
||||
}
|
||||
if (message == 'close') {
|
||||
core.connection.stop();
|
||||
SystemNavigator.pop();
|
||||
AndroidFlutterLocalNotificationsPlugin().stopForegroundService();
|
||||
AndroidFlutterLocalNotificationsPlugin().cancelAll();
|
||||
}
|
||||
|
||||
//exit(0);
|
||||
});
|
||||
}
|
||||
@@ -246,7 +296,7 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
void notificationTapBackground(NotificationResponse notificationResponse) {
|
||||
if (notificationResponse.actionId != null) {
|
||||
final sendPort = IsolateNameServer.lookupPortByName('_backgroundChannelKey');
|
||||
sendPort?.send('notificationResponse');
|
||||
sendPort?.send(notificationResponse.actionId);
|
||||
//exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
@@ -14,10 +9,15 @@ import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class KeyboardRequirement extends PlatformRequirement {
|
||||
KeyboardRequirement() : super(AppLocalizations.current.keyboardAccess);
|
||||
KeyboardRequirement() : super(AppLocalizations.current.keyboardAccess, icon: Icons.keyboard);
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
@@ -36,7 +36,8 @@ class KeyboardRequirement extends PlatformRequirement {
|
||||
}
|
||||
|
||||
class BluetoothAdvertiseRequirement extends PlatformRequirement {
|
||||
BluetoothAdvertiseRequirement() : super(AppLocalizations.current.bluetoothAdvertiseAccess);
|
||||
BluetoothAdvertiseRequirement()
|
||||
: super(AppLocalizations.current.bluetoothAdvertiseAccess, icon: Icons.bluetooth_audio);
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
@@ -51,7 +52,7 @@ class BluetoothAdvertiseRequirement extends PlatformRequirement {
|
||||
}
|
||||
|
||||
class BluetoothTurnedOn extends PlatformRequirement {
|
||||
BluetoothTurnedOn() : super(AppLocalizations.current.bluetoothTurnedOn);
|
||||
BluetoothTurnedOn() : super(AppLocalizations.current.bluetoothTurnedOn, icon: Icons.bluetooth);
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
@@ -99,6 +100,7 @@ class UnsupportedPlatform extends PlatformRequirement {
|
||||
kIsWeb
|
||||
? AppLocalizations.current.browserNotSupported
|
||||
: AppLocalizations.current.platformNotSupported('platform'),
|
||||
icon: Icons.error_outline,
|
||||
) {
|
||||
status = false;
|
||||
}
|
||||
@@ -113,7 +115,7 @@ class UnsupportedPlatform extends PlatformRequirement {
|
||||
}
|
||||
|
||||
class ErrorRequirement extends PlatformRequirement {
|
||||
ErrorRequirement(super.name) {
|
||||
ErrorRequirement(super.name, {required super.icon}) {
|
||||
status = false;
|
||||
}
|
||||
|
||||
@@ -194,16 +196,3 @@ enum Target {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class PlaceholderRequirement extends PlatformRequirement {
|
||||
PlaceholderRequirement() : super(AppLocalizations.current.requirement);
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
|
||||
|
||||
@override
|
||||
Future<bool> getStatus() async {
|
||||
status = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import 'package:flutter/material.dart';
|
||||
abstract class PlatformRequirement {
|
||||
String name;
|
||||
String? description;
|
||||
final IconData icon;
|
||||
late bool status;
|
||||
|
||||
PlatformRequirement(this.name, {this.description});
|
||||
PlatformRequirement(this.name, {this.description, required this.icon});
|
||||
|
||||
Future<bool> getStatus();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider_windows/path_provider_windows.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -26,6 +27,12 @@ class Settings {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
|
||||
|
||||
if (getShowOnboarding() && getTrainerApp() != null) {
|
||||
// If onboarding is to be shown, but a trainer app is already set,
|
||||
// skip onboarding and set to not show again.
|
||||
await setShowOnboarding(false);
|
||||
}
|
||||
|
||||
if (core.actionHandler is DesktopActions) {
|
||||
// Must add this line.
|
||||
await windowManager.ensureInitialized();
|
||||
@@ -181,6 +188,7 @@ class Settings {
|
||||
Future<void> setLastTarget(Target target) async {
|
||||
await prefs.setString('last_target', target.name);
|
||||
initializeActions(target.connectionType);
|
||||
IAPManager.instance.setAttributes();
|
||||
}
|
||||
|
||||
Future<void> setLastSeenVersion(String version) async {
|
||||
@@ -374,4 +382,20 @@ class Settings {
|
||||
final v = ms.clamp(_sramAxsDoubleClickWindowMinMs, _sramAxsDoubleClickWindowMaxMs);
|
||||
await prefs.setInt('sram_axs_double_click_window_ms', v);
|
||||
}
|
||||
|
||||
bool getShowOnboarding() {
|
||||
return !kIsWeb && (prefs.getBool('show_onboarding') ?? true);
|
||||
}
|
||||
|
||||
Future<void> setShowOnboarding(bool show) async {
|
||||
await prefs.setBool('show_onboarding', show);
|
||||
}
|
||||
|
||||
bool hasAskedPermissions() {
|
||||
return prefs.getBool('asked_permissions') ?? false;
|
||||
}
|
||||
|
||||
Future<void> setHasAskedPermissions(bool asked) async {
|
||||
await prefs.setBool('asked_permissions', asked);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ class _LocalTileState extends State<LocalTile> {
|
||||
isEnabled: core.settings.getLocalEnabled(),
|
||||
type: ConnectionMethodType.local,
|
||||
showTroubleshooting: true,
|
||||
instructionLink: 'INSTRUCTIONS_LOCAL.md',
|
||||
title: context.i18n.controlAppUsingModes(
|
||||
core.settings.getTrainerApp()?.name ?? '',
|
||||
core.actionHandler.supportedModes.joinToString(transform: (e) => e.name.capitalize()),
|
||||
|
||||
@@ -42,17 +42,16 @@ class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
|
||||
core.whooshLink.stopServer();
|
||||
} else if (value) {
|
||||
buildToast(
|
||||
context,
|
||||
navigatorKey.currentContext!,
|
||||
title: AppLocalizations.of(context).myWhooshLinkInfo,
|
||||
level: LogLevel.LOGLEVEL_INFO,
|
||||
duration: Duration(seconds: 6),
|
||||
closeTitle: 'Open',
|
||||
onClose: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'INSTRUCTIONS_MYWHOOSH_LINK.md'),
|
||||
),
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (c) => MarkdownPage(assetPath: 'INSTRUCTIONS_MYWHOOSH_LINK.md'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class OpenBikeControlBluetoothTile extends StatefulWidget {
|
||||
@@ -27,7 +28,9 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlBluetoothTile> {
|
||||
type: ConnectionMethodType.openBikeControl,
|
||||
title: context.i18n.connectUsingBluetooth,
|
||||
description: isConnected != null
|
||||
? context.i18n.connectedTo(isConnected.appId)
|
||||
? context.i18n.connectedTo(
|
||||
"${isConnected.appId}:\n${isConnected.supportedActions.joinToString(transform: (s) => s.title)}",
|
||||
)
|
||||
: isStarted
|
||||
? context.i18n.chooseBikeControlInConnectionScreen
|
||||
: context.i18n.letsAppConnectOverBluetooth(core.settings.getTrainerApp()?.name ?? ''),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class OpenBikeControlMdnsTile extends StatefulWidget {
|
||||
@@ -28,7 +29,9 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlMdnsTile> {
|
||||
title: context.i18n.connectDirectlyOverNetwork,
|
||||
|
||||
description: isConnected != null
|
||||
? context.i18n.connectedTo(isConnected.appId)
|
||||
? context.i18n.connectedTo(
|
||||
"${isConnected.appId}:\n${isConnected.supportedActions.joinToString(transform: (s) => s.title)}",
|
||||
)
|
||||
: isStarted
|
||||
? context.i18n.chooseBikeControlInConnectionScreen
|
||||
: context.i18n.letsAppConnectOverNetwork(core.settings.getTrainerApp()?.name ?? ''),
|
||||
|
||||
@@ -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(
|
||||
@@ -40,7 +42,7 @@ class ChangelogDialog extends StatelessWidget {
|
||||
|
||||
static Future<void> showIfNeeded(BuildContext context, String currentVersion, String? lastSeenVersion) async {
|
||||
// Show dialog if this is a new version
|
||||
if (lastSeenVersion != currentVersion && !screenshotMode) {
|
||||
if (lastSeenVersion != currentVersion && lastSeenVersion != null && !screenshotMode) {
|
||||
try {
|
||||
final entry = await rootBundle.loadString('CHANGELOG.md');
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.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/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: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';
|
||||
|
||||
/// Widget to display IAP status and allow purchases
|
||||
class IAPStatusWidget extends StatefulWidget {
|
||||
@@ -16,9 +25,19 @@ class IAPStatusWidget extends StatefulWidget {
|
||||
State<IAPStatusWidget> createState() => _IAPStatusWidgetState();
|
||||
}
|
||||
|
||||
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;
|
||||
AlreadyBoughtOption? _alreadyBoughtQuestion;
|
||||
|
||||
final _purchaseIdField = const TextFieldKey(#purchaseId);
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -47,160 +66,404 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
|
||||
final commandsRemaining = iapManager.commandsRemainingToday;
|
||||
final dailyCommandCount = iapManager.dailyCommandCount;
|
||||
|
||||
return Button(
|
||||
onPressed: _isSmall
|
||||
? () {
|
||||
setState(() {
|
||||
_isSmall = false;
|
||||
});
|
||||
}
|
||||
: _handlePurchase,
|
||||
style: ButtonStyle.card().withBackgroundColor(
|
||||
color: Theme.of(context).colorScheme.muted,
|
||||
hoverColor: Theme.of(context).colorScheme.primaryForeground,
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 700),
|
||||
width: double.infinity,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: IAPManager.instance.isPurchased,
|
||||
builder: (context, isPurchased, child) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPurchased) ...[
|
||||
Row(
|
||||
return kIsWeb
|
||||
? SizedBox()
|
||||
: Button(
|
||||
onPressed: _isSmall
|
||||
? () {
|
||||
setState(() {
|
||||
_isSmall = false;
|
||||
});
|
||||
}
|
||||
: () {
|
||||
if (Platform.isAndroid) {
|
||||
if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) {
|
||||
_handlePurchase(context);
|
||||
}
|
||||
} else {
|
||||
_handlePurchase(context);
|
||||
}
|
||||
},
|
||||
style: ButtonStyle.card().withBackgroundColor(
|
||||
color: Theme.of(context).colorScheme.muted,
|
||||
hoverColor: Theme.of(context).colorScheme.primaryForeground,
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 700),
|
||||
width: double.infinity,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: IAPManager.instance.isPurchased,
|
||||
builder: (context, isPurchased, child) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context).fullVersion,
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (!isTrialExpired) ...[
|
||||
if (!Platform.isAndroid)
|
||||
Basic(
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
leading: Icon(Icons.access_time, color: Colors.blue),
|
||||
title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)),
|
||||
subtitle: _isSmall
|
||||
? null
|
||||
: Text(AppLocalizations.of(context).trialPeriodDescription(IAPManager.dailyCommandLimit)),
|
||||
trailing: _isSmall ? Icon(Icons.expand_more) : null,
|
||||
)
|
||||
else
|
||||
Basic(
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
leading: Icon(Icons.lock),
|
||||
title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
SizedBox(),
|
||||
Text(
|
||||
commandsRemaining >= 0
|
||||
? context.i18n
|
||||
.commandsRemainingToday(commandsRemaining, IAPManager.dailyCommandLimit)
|
||||
.replaceAll(
|
||||
'${IAPManager.dailyCommandLimit}/${IAPManager.dailyCommandLimit}',
|
||||
IAPManager.dailyCommandLimit.toString(),
|
||||
)
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit),
|
||||
).small,
|
||||
if (commandsRemaining >= 0)
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: LinearProgressIndicator(
|
||||
value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(),
|
||||
backgroundColor: Colors.gray[300],
|
||||
color: commandsRemaining > 0 ? Colors.orange : Colors.red,
|
||||
if (isPurchased) ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context).fullVersion,
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: _isSmall ? Icon(Icons.expand_more) : null,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
),
|
||||
] else ...[
|
||||
Basic(
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
leading: Icon(Icons.lock),
|
||||
title: Text(AppLocalizations.of(context).trialExpired(IAPManager.dailyCommandLimit)),
|
||||
trailing: _isSmall ? Icon(Icons.expand_more) : null,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
SizedBox(),
|
||||
Text(
|
||||
commandsRemaining >= 0
|
||||
? context.i18n.commandsRemainingToday(commandsRemaining, IAPManager.dailyCommandLimit)
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit),
|
||||
).small,
|
||||
if (commandsRemaining >= 0)
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: LinearProgressIndicator(
|
||||
value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(),
|
||||
backgroundColor: Colors.gray[300],
|
||||
color: commandsRemaining > 0 ? Colors.orange : Colors.red,
|
||||
],
|
||||
),
|
||||
] else if (!isTrialExpired) ...[
|
||||
if (!Platform.isAndroid)
|
||||
Basic(
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
leading: Icon(Icons.access_time, color: Colors.blue),
|
||||
title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)),
|
||||
subtitle: _isSmall
|
||||
? null
|
||||
: Text(
|
||||
AppLocalizations.of(context).trialPeriodDescription(IAPManager.dailyCommandLimit),
|
||||
),
|
||||
trailing: _isSmall ? Icon(Icons.expand_more) : null,
|
||||
)
|
||||
else
|
||||
Basic(
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
leading: Icon(Icons.lock),
|
||||
title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
SizedBox(),
|
||||
Text(
|
||||
commandsRemaining >= 0
|
||||
? context.i18n
|
||||
.commandsRemainingToday(commandsRemaining, IAPManager.dailyCommandLimit)
|
||||
.replaceAll(
|
||||
'${IAPManager.dailyCommandLimit}/${IAPManager.dailyCommandLimit}',
|
||||
IAPManager.dailyCommandLimit.toString(),
|
||||
)
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit),
|
||||
).small,
|
||||
if (commandsRemaining >= 0 && dailyCommandCount > 0)
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: LinearProgressIndicator(
|
||||
value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(),
|
||||
backgroundColor: Colors.gray[300],
|
||||
color: commandsRemaining > 0 ? Colors.orange : Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: _isSmall ? Icon(Icons.expand_more) : null,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
),
|
||||
] else ...[
|
||||
Basic(
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
leading: Icon(Icons.lock),
|
||||
title: Text(AppLocalizations.of(context).trialExpired(IAPManager.dailyCommandLimit)),
|
||||
trailing: _isSmall ? Icon(Icons.expand_more) : null,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
SizedBox(),
|
||||
Text(
|
||||
commandsRemaining >= 0
|
||||
? context.i18n.commandsRemainingToday(
|
||||
commandsRemaining,
|
||||
IAPManager.dailyCommandLimit,
|
||||
)
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit),
|
||||
).small,
|
||||
if (commandsRemaining >= 0)
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: LinearProgressIndicator(
|
||||
value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(),
|
||||
backgroundColor: Colors.gray[300],
|
||||
color: commandsRemaining > 0 ? Colors.orange : Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!isPurchased && !_isSmall) ...[
|
||||
if (Platform.isAndroid)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 42.0, top: 16.0),
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Divider(),
|
||||
const SizedBox(),
|
||||
if (_alreadyBoughtQuestion == null && DateTime.now().isBefore(_normalDate)) ...[
|
||||
Text(AppLocalizations.of(context).alreadyBoughtTheAppPreviously).small,
|
||||
Row(
|
||||
children: [
|
||||
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),
|
||||
OutlineButton(
|
||||
child: Text(AppLocalizations.of(context).no),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_alreadyBoughtQuestion = AlreadyBoughtOption.no;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.fullPurchase) ...[
|
||||
Text(
|
||||
AppLocalizations.of(context).alreadyBoughtTheApp,
|
||||
).small,
|
||||
Form(
|
||||
onSubmit: (context, values) async {
|
||||
String purchaseId = _purchaseIdField[values]!.trim();
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
final redeemed = await _redeemPurchase(
|
||||
purchaseId: purchaseId,
|
||||
supabaseAnonKey:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpa3JjeXlub3Zkdm9ncmxkZm53Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYwNjMyMzksImV4cCI6MjA4MTYzOTIzOX0.oxJovYahRiZ6XvCVR-qww6OQ5jY6cjOyUiFHJsW9MVk',
|
||||
supabaseUrl: 'https://pikrcyynovdvogrldfnw.supabase.co',
|
||||
);
|
||||
if (redeemed) {
|
||||
await IAPManager.instance.redeem(purchaseId);
|
||||
buildToast(
|
||||
context,
|
||||
title: 'Success',
|
||||
subtitle: 'Purchase redeemed successfully!',
|
||||
);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Error'),
|
||||
content: Text(
|
||||
'Failed to redeem purchase. Please check your Purchase ID and try again or contact me directly. Sorry about that!',
|
||||
),
|
||||
actions: [
|
||||
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',
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: FormField(
|
||||
showErrors: {
|
||||
FormValidationMode.submitted,
|
||||
FormValidationMode.changed,
|
||||
},
|
||||
key: _purchaseIdField,
|
||||
label: Text('Purchase ID'),
|
||||
validator: RegexValidator(
|
||||
RegExp(r'GPA.[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{5}'),
|
||||
message: 'Please enter a valid Purchase ID.',
|
||||
),
|
||||
child: TextField(
|
||||
placeholder: Text('GPA.****-****-****-*****'),
|
||||
),
|
||||
),
|
||||
),
|
||||
FormErrorBuilder(
|
||||
builder: (context, errors, child) {
|
||||
return PrimaryButton(
|
||||
onPressed: errors.isEmpty ? () => context.submitForm() : null,
|
||||
child: _isLoading
|
||||
? SmallProgressIndicator(color: Colors.black)
|
||||
: const Text('Submit'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.no) ...[
|
||||
PrimaryButton(
|
||||
onPressed: _isPurchasing ? null : () => _handlePurchase(context),
|
||||
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).fullVersionDescription).xSmall,
|
||||
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) ...[
|
||||
PrimaryButton(
|
||||
onPressed: _isPurchasing ? null : () => _handlePurchase(context),
|
||||
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',
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 42.0),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return PrimaryButton(
|
||||
onPressed: _isPurchasing ? null : () => _handlePurchase(context),
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (Platform.isMacOS)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 42.0, top: 8.0, bottom: 8),
|
||||
child: LoadingWidget(
|
||||
futureCallback: () async {
|
||||
await IAPManager.instance.restorePurchases();
|
||||
},
|
||||
renderChild: (isLoading, tap) => LinkButton(
|
||||
onPressed: tap,
|
||||
child: isLoading ? SmallProgressIndicator() : const Text('Restore Purchase').small,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 42.0, top: 8.0),
|
||||
child: Text(AppLocalizations.of(context).fullVersionDescription).xSmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!IAPManager.instance.isPurchased.value && !_isSmall) ...[
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 42.0),
|
||||
child: 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),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 42.0, top: 8.0),
|
||||
child: Text(AppLocalizations.of(context).fullVersionDescription).xSmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase() async {
|
||||
Future<void> _handlePurchase(BuildContext context) async {
|
||||
setState(() {
|
||||
_isPurchasing = true;
|
||||
});
|
||||
|
||||
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(
|
||||
@@ -217,4 +480,37 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _redeemPurchase({
|
||||
required String supabaseUrl,
|
||||
required String supabaseAnonKey,
|
||||
required String purchaseId,
|
||||
}) async {
|
||||
final uri = Uri.parse('$supabaseUrl/functions/v1/redeem-purchase');
|
||||
|
||||
final appUserId = await Purchases.appUserID;
|
||||
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $supabaseAnonKey',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'purchaseId': purchaseId,
|
||||
'userId': appUserId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final body = response.body;
|
||||
final decoded = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
core.connection.signalNotification(LogNotification(body));
|
||||
|
||||
return decoded['success'] == true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart' show SelectionArea;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
@@ -76,36 +77,39 @@ class _LogviewerState extends State<LogViewer> {
|
||||
: Expanded(
|
||||
child: Card(
|
||||
child: SelectionArea(
|
||||
child: ListView(
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
reverse: true,
|
||||
children: core.connection.lastLogEntries
|
||||
.map(
|
||||
(action) => Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: action.date.toString().split(" ").last,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontFamily: "monospace",
|
||||
fontFamilyFallback: <String>["Courier"],
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: core.connection.lastLogEntries
|
||||
.map(
|
||||
(action) => [
|
||||
TextSpan(
|
||||
text: action.date.toString().split(" ").last,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontFamily: "monospace",
|
||||
fontFamilyFallback: <String>["Courier"],
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " ${action.entry}",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontWeight: FontWeight.bold,
|
||||
TextSpan(
|
||||
text: " ${action.entry}\n",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
],
|
||||
)
|
||||
.flatten()
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/pages/navigation.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
@@ -12,212 +11,85 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' show showLicensePage;
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:purchases_flutter/purchases_flutter.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../utils/iap/iap_manager.dart';
|
||||
|
||||
List<Widget> buildMenuButtons(BuildContext context, BCPage currentPage, VoidCallback? openLogs) {
|
||||
return [
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return OutlineButton(
|
||||
density: ButtonDensity.icon,
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
if ((!Platform.isIOS && !Platform.isMacOS)) ...[
|
||||
MenuLabel(child: Text(context.i18n.showDonation)),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.donateViaCreditCard),
|
||||
onPressed: (c) {
|
||||
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
|
||||
final link = switch (currency.currencyName) {
|
||||
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
|
||||
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
|
||||
};
|
||||
launchUrlString(link);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && Platform.isAndroid && isFromPlayStore == false)
|
||||
if (IAPManager.instance.isPurchased.value)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return OutlineButton(
|
||||
density: ButtonDensity.icon,
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
if ((!Platform.isIOS && !Platform.isMacOS)) ...[
|
||||
MenuLabel(child: Text(context.i18n.showDonation)),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.donateByBuyingFromPlayStore),
|
||||
child: Text(context.i18n.donateViaCreditCard),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol');
|
||||
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
|
||||
final link = switch (currency.currencyName) {
|
||||
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
|
||||
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
|
||||
};
|
||||
launchUrlString(link);
|
||||
},
|
||||
),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.donateViaPaypal),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://paypal.me/boni');
|
||||
},
|
||||
),
|
||||
],
|
||||
MenuButton(
|
||||
leading: Icon(Icons.star_rate),
|
||||
child: Text(context.i18n.leaveAReview),
|
||||
onPressed: (c) async {
|
||||
final InAppReview inAppReview = InAppReview.instance;
|
||||
|
||||
if (await inAppReview.isAvailable()) {
|
||||
inAppReview.requestReview();
|
||||
} else {
|
||||
inAppReview.openStoreListing(appStoreId: 'id6753721284', microsoftStoreId: '9NP42GS03Z26');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.favorite,
|
||||
color: Colors.red,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Gap(4),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return OutlineButton(
|
||||
density: ButtonDensity.icon,
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
leading: Icon(Icons.help_outline),
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
onPressed: (c) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuDivider(),
|
||||
MenuLabel(child: Text(context.i18n.getSupport)),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.reddit_outlined),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://www.reddit.com/r/BikeControl/');
|
||||
},
|
||||
child: Text('Reddit'),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.facebook_outlined),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://www.facebook.com/groups/1892836898778912');
|
||||
},
|
||||
child: Text('Facebook'),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(RadixIcons.githubLogo),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
},
|
||||
child: Text('GitHub'),
|
||||
),
|
||||
if (!kIsWeb) ...[
|
||||
MenuButton(
|
||||
leading: Icon(Icons.email_outlined),
|
||||
child: Text('Mail'),
|
||||
onPressed: (c) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Mail Support'),
|
||||
content: Container(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 16,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).mailSupportExplanation,
|
||||
),
|
||||
...[
|
||||
OutlineButton(
|
||||
leading: Icon(Icons.reddit_outlined),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString('https://www.reddit.com/r/BikeControl/');
|
||||
},
|
||||
child: const Text('Reddit'),
|
||||
),
|
||||
OutlineButton(
|
||||
leading: Icon(Icons.facebook_outlined),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString('https://www.facebook.com/groups/1892836898778912');
|
||||
},
|
||||
child: const Text('Facebook'),
|
||||
),
|
||||
OutlineButton(
|
||||
leading: Icon(RadixIcons.githubLogo),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
},
|
||||
child: const Text('GitHub'),
|
||||
),
|
||||
SecondaryButton(
|
||||
leading: Icon(Icons.mail_outlined),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
|
||||
final isFromStore = (Platform.isAndroid
|
||||
? isFromPlayStore == true
|
||||
: Platform.isIOS);
|
||||
final suffix = isFromStore ? '' : '-sw';
|
||||
|
||||
String email = Uri.encodeComponent('jonas$suffix@bikecontrol.app');
|
||||
String subject = Uri.encodeComponent(
|
||||
context.i18n.helpRequested(packageInfoValue?.version ?? ''),
|
||||
);
|
||||
String body = Uri.encodeComponent("""
|
||||
${debugText()}""");
|
||||
Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body");
|
||||
|
||||
launchUrl(mail);
|
||||
},
|
||||
child: const Text('Mail'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!kIsWeb && Platform.isAndroid && isFromPlayStore == false)
|
||||
MenuButton(
|
||||
child: Text(context.i18n.donateByBuyingFromPlayStore),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol');
|
||||
},
|
||||
);
|
||||
),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.donateViaPaypal),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://paypal.me/boni');
|
||||
},
|
||||
),
|
||||
],
|
||||
MenuButton(
|
||||
leading: Icon(Icons.star_rate),
|
||||
child: Text(context.i18n.leaveAReview),
|
||||
onPressed: (c) async {
|
||||
final InAppReview inAppReview = InAppReview.instance;
|
||||
|
||||
if (await inAppReview.isAvailable()) {
|
||||
inAppReview.requestReview();
|
||||
} else {
|
||||
inAppReview.openStoreListing(appStoreId: 'id6753721284', microsoftStoreId: '9NP42GS03Z26');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.help_outline,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
Icons.favorite,
|
||||
color: Colors.red,
|
||||
size: 18,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Gap(4),
|
||||
|
||||
BKMenuButton(openLogs: openLogs, currentPage: currentPage),
|
||||
];
|
||||
}
|
||||
|
||||
String debugText() {
|
||||
Future<String> debugText() async {
|
||||
final userId = IAPManager.instance.isUsingRevenueCat ? (await Purchases.appUserID) : null;
|
||||
return '''
|
||||
|
||||
---
|
||||
@@ -227,6 +99,7 @@ Target: ${core.settings.getLastTarget()?.name ?? '-'}
|
||||
Trainer App: ${core.settings.getTrainerApp()?.name ?? '-'}
|
||||
Connected Controllers: ${core.connection.devices.map((e) => e.toString()).join(', ')}
|
||||
Connected Trainers: ${core.logic.connectedTrainerConnections.map((e) => e.title).join(', ')}
|
||||
Status: ${IAPManager.instance.isPurchased.value ? 'Full Version' : 'Test Version'}${userId != null ? ' (User ID: $userId)' : ''}
|
||||
Logs:
|
||||
${core.connection.lastLogEntries.reversed.joinToString(separator: '\n', transform: (e) => '${e.date.toString().split('.').first} - ${e.entry}')}
|
||||
''';
|
||||
@@ -250,6 +123,7 @@ class BKMenuButton extends StatelessWidget {
|
||||
MenuButton(
|
||||
child: Text(context.i18n.continueAction),
|
||||
onPressed: (c) {
|
||||
IAPManager.instance.purchaseFullVersion(context);
|
||||
core.connection.addDevices([
|
||||
ZwiftClickV2(
|
||||
BleDevice(
|
||||
@@ -293,7 +167,11 @@ class BKMenuButton extends StatelessWidget {
|
||||
leading: Icon(Icons.update_outlined),
|
||||
child: Text(context.i18n.changelog),
|
||||
onPressed: (c) {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md'),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuButton(
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/button_simulator.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/ignored_devices_dialog.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:bike_control/widgets/ui/wifi_animation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ScanWidget extends StatefulWidget {
|
||||
const ScanWidget({super.key});
|
||||
@@ -36,6 +31,7 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_needsPermissions != null && _needsPermissions!.isNotEmpty)
|
||||
Card(
|
||||
@@ -70,17 +66,18 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(),
|
||||
Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
SizedBox(),
|
||||
SmoothWifiAnimation(),
|
||||
Expanded(
|
||||
child: Text(context.i18n.scanningForDevices).small.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (core.connection.controllerDevices.isEmpty)
|
||||
Column(
|
||||
spacing: 14,
|
||||
children: [
|
||||
SizedBox(),
|
||||
SmoothWifiAnimation(),
|
||||
Text(
|
||||
context.i18n.scanningForDevices,
|
||||
textAlign: TextAlign.center,
|
||||
).small.muted,
|
||||
],
|
||||
),
|
||||
SizedBox(),
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows))
|
||||
ValueListenableBuilder(
|
||||
@@ -100,7 +97,7 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS))
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && !core.settings.getShowOnboarding())
|
||||
Checkbox(
|
||||
state: core.settings.getPhoneSteeringEnabled()
|
||||
? CheckboxState.checked
|
||||
@@ -113,58 +110,6 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
},
|
||||
),
|
||||
SizedBox(),
|
||||
if (!screenshotMode)
|
||||
Column(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
|
||||
);
|
||||
},
|
||||
leading: Icon(Icons.help_outline),
|
||||
child: Text(context.i18n.showTroubleshootingGuide),
|
||||
),
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
launchUrlString(
|
||||
'https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-devices',
|
||||
);
|
||||
},
|
||||
leading: Icon(Icons.gamepad_outlined),
|
||||
child: Text(context.i18n.showSupportedControllers),
|
||||
),
|
||||
if (core.settings.getIgnoredDevices().isNotEmpty)
|
||||
OutlineButton(
|
||||
leading: Icon(Icons.block_outlined),
|
||||
onPressed: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => IgnoredDevicesDialog(),
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(context.i18n.manageIgnoredDevices),
|
||||
),
|
||||
|
||||
if (core.connection.controllerDevices.isEmpty)
|
||||
PrimaryButton(
|
||||
leading: Icon(Icons.computer_outlined),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => ButtonSimulator(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context).noControllerUseCompanionMode),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -49,7 +49,7 @@ class Testbed extends StatefulWidget {
|
||||
State<Testbed> createState() => _TestbedState();
|
||||
}
|
||||
|
||||
class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
late final Ticker _ticker;
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
|
||||
@@ -67,6 +67,8 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
|
||||
Offset? _lastMove;
|
||||
|
||||
bool _isInBackground = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -74,17 +76,34 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
_isMobile = MediaQuery.sizeOf(context).width < 600;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_isInBackground = state == AppLifecycleState.paused || state == AppLifecycleState.hidden;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_focusNode = FocusNode(debugLabel: 'TestbedFocus', canRequestFocus: true, skipTraversal: true);
|
||||
_actionSubscription = core.connection.actionStream.listen((data) async {
|
||||
if (!mounted) {
|
||||
if (!mounted || (_isInBackground && data is! AlertNotification)) {
|
||||
return;
|
||||
}
|
||||
if (data is ButtonNotification && data.buttonsClicked.isNotEmpty) {
|
||||
if (core.actionHandler.supportedApp == null) {
|
||||
if (core.settings.getShowOnboarding()) {
|
||||
final button = data.buttonsClicked.first;
|
||||
final sample = _KeySample(
|
||||
button: button,
|
||||
text: '🔘 ${button.name}',
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
_keys.insert(0, sample);
|
||||
if (_keys.length > widget.maxKeyboardEvents) {
|
||||
_keys.removeLast();
|
||||
}
|
||||
} else if (core.actionHandler.supportedApp == null) {
|
||||
buildToast(context, level: LogLevel.LOGLEVEL_WARNING, title: context.i18n.selectTrainerAppAndTarget);
|
||||
} else {
|
||||
final button = data.buttonsClicked.first;
|
||||
@@ -162,6 +181,7 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
void dispose() {
|
||||
_ticker.dispose();
|
||||
_focusNode.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -215,29 +235,6 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (!widget.enabled || !widget.showKeyboard || event is KeyUpEvent) return KeyEventResult.ignored;
|
||||
|
||||
final label = event.logicalKey.keyLabel;
|
||||
final keyName = label.isNotEmpty ? label : event.logicalKey.debugName ?? 'Key';
|
||||
final isDown = event is KeyDownEvent;
|
||||
final isUp = event is KeyUpEvent;
|
||||
|
||||
buildToast(
|
||||
context,
|
||||
|
||||
location: ToastLocation.bottomLeft,
|
||||
title:
|
||||
'${isDown
|
||||
? "↓"
|
||||
: isUp
|
||||
? "↑"
|
||||
: "•"} $keyName',
|
||||
);
|
||||
// We don't want to prevent normal text input, so we return ignored.
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
@@ -251,7 +248,6 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
autofocus: true,
|
||||
canRequestFocus: true,
|
||||
descendantsAreFocusable: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
@@ -271,7 +267,7 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
if (widget.showKeyboard)
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: _isMobile ? 92 : 12,
|
||||
bottom: _isMobile && !core.settings.getShowOnboarding() ? 92 : 12,
|
||||
child: IgnorePointer(
|
||||
child: _KeyboardOverlay(
|
||||
items: _keys,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/widgets/ui/gradient_text.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';
|
||||
@@ -29,12 +30,23 @@ class AppTitle extends StatefulWidget {
|
||||
State<AppTitle> createState() => _AppTitleState();
|
||||
}
|
||||
|
||||
class _AppTitleState extends State<AppTitle> {
|
||||
enum UpdateType {
|
||||
playStore,
|
||||
shorebird,
|
||||
appStore,
|
||||
windowsStore,
|
||||
}
|
||||
|
||||
class _AppTitleState extends State<AppTitle> with WidgetsBindingObserver {
|
||||
final updater = ShorebirdUpdater();
|
||||
|
||||
Version? _newVersion;
|
||||
UpdateType? _updateType;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
if (updater.isAvailable) {
|
||||
updater.readCurrentPatch().then((patch) {
|
||||
@@ -54,6 +66,19 @@ class _AppTitleState extends State<AppTitle> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_checkForUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _checkForUpdate() async {
|
||||
if (screenshotMode) {
|
||||
return;
|
||||
@@ -63,13 +88,27 @@ class _AppTitleState extends State<AppTitle> {
|
||||
updater
|
||||
.update()
|
||||
.then((value) {
|
||||
_showShorebirdRestartSnackbar();
|
||||
setState(() {
|
||||
_updateType = UpdateType.shorebird;
|
||||
});
|
||||
})
|
||||
.catchError((e) {
|
||||
buildToast(context, title: AppLocalizations.current.failedToUpdate(e.toString()));
|
||||
});
|
||||
} else if (updateStatus == UpdateStatus.restartRequired) {
|
||||
_showShorebirdRestartSnackbar();
|
||||
_updateType = UpdateType.shorebird;
|
||||
}
|
||||
if (_updateType == UpdateType.shorebird) {
|
||||
final nextPatch = await updater.readNextPatch();
|
||||
setState(() {
|
||||
final currentVersion = Version.parse(packageInfoValue!.version);
|
||||
_newVersion = Version(
|
||||
currentVersion.major,
|
||||
currentVersion.minor,
|
||||
currentVersion.patch,
|
||||
build: nextPatch?.number.toString() ?? '',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,14 +118,9 @@ class _AppTitleState extends State<AppTitle> {
|
||||
try {
|
||||
final appUpdateInfo = await InAppUpdate.checkForUpdate();
|
||||
if (context.mounted && appUpdateInfo.updateAvailability == UpdateAvailability.updateAvailable) {
|
||||
buildToast(
|
||||
context,
|
||||
title: AppLocalizations.current.newVersionAvailable,
|
||||
closeTitle: AppLocalizations.current.update,
|
||||
onClose: () {
|
||||
InAppUpdate.performImmediateUpdate();
|
||||
},
|
||||
);
|
||||
setState(() {
|
||||
_updateType = UpdateType.playStore;
|
||||
});
|
||||
}
|
||||
isFromPlayStore = true;
|
||||
return null;
|
||||
@@ -113,7 +147,7 @@ class _AppTitleState extends State<AppTitle> {
|
||||
|
||||
final body = res.body;
|
||||
final regex = RegExp(
|
||||
r'whats-new__latest__version">Version ([0-9]{1,2}\.[0-9]{1,2}.[0-9]{1,2})</p>',
|
||||
r'>Version ([0-9]{1,2}\.[0-9]{1,2}.[0-9]{1,2})</h4>',
|
||||
dotAll: true,
|
||||
);
|
||||
final match = regex.firstMatch(body);
|
||||
@@ -140,33 +174,69 @@ class _AppTitleState extends State<AppTitle> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GradientText('BikeControl', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22)),
|
||||
GradientText(
|
||||
'BikeControl',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22),
|
||||
),
|
||||
if (packageInfoValue != null)
|
||||
Text(
|
||||
'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''} - ${IAPManager.instance.getStatusMessage()}',
|
||||
'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''} - ${core.settings.getShowOnboarding() ? 'Onboarding' : IAPManager.instance.getStatusMessage()}',
|
||||
style: TextStyle(fontSize: 12),
|
||||
).mono.muted
|
||||
else
|
||||
SmallProgressIndicator(),
|
||||
|
||||
if (_newVersion != null && _updateType != null)
|
||||
Container(
|
||||
margin: EdgeInsets.only(top: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: LoadingWidget(
|
||||
futureCallback: () async {
|
||||
if (_updateType == UpdateType.shorebird) {
|
||||
await _shorebirdRestart();
|
||||
} else if (_updateType == UpdateType.playStore) {
|
||||
await launchUrlString(
|
||||
'https://play.google.com/store/apps/details?id=org.jonasbark.swiftcontrol',
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
} else if (_updateType == UpdateType.appStore) {
|
||||
await launchUrlString(
|
||||
'https://apps.apple.com/app/id6753721284',
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
} else if (_updateType == UpdateType.windowsStore) {
|
||||
await launchUrlString(
|
||||
'ms-windows-store://pdp/?productid=9NP42GS03Z26',
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
},
|
||||
renderChild: (isLoading, tap) => GhostButton(
|
||||
onPressed: tap,
|
||||
trailing: isLoading ? SmallProgressIndicator() : Icon(Icons.update),
|
||||
child: Text(AppLocalizations.current.newVersionAvailableWithVersion(_newVersion.toString())),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showShorebirdRestartSnackbar() {
|
||||
buildToast(
|
||||
context,
|
||||
title: AppLocalizations.current.forceCloseToUpdate,
|
||||
closeTitle: AppLocalizations.current.restart,
|
||||
onClose: () {
|
||||
if (Platform.isIOS) {
|
||||
core.connection.reset();
|
||||
Restart.restartApp(delayBeforeRestart: 1000);
|
||||
} else {
|
||||
core.connection.reset();
|
||||
exit(0);
|
||||
}
|
||||
},
|
||||
);
|
||||
Future<void> _shorebirdRestart() async {
|
||||
setState(() {
|
||||
core.connection.disconnectAll();
|
||||
core.connection.stop();
|
||||
if (Platform.isIOS) {
|
||||
Restart.restartApp(delayBeforeRestart: 1000);
|
||||
} else {
|
||||
exit(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _compareVersion(String versionString) {
|
||||
@@ -174,23 +244,21 @@ class _AppTitleState extends State<AppTitle> {
|
||||
final current = Version.parse(packageInfoValue!.version);
|
||||
if (parsed > current && mounted && !kDebugMode) {
|
||||
if (Platform.isAndroid) {
|
||||
_showUpdateSnackbar(parsed, 'https://play.google.com/store/apps/details?id=org.jonasbark.swiftcontrol');
|
||||
setState(() {
|
||||
_updateType = UpdateType.playStore;
|
||||
_newVersion = parsed;
|
||||
});
|
||||
} else if (Platform.isIOS || Platform.isMacOS) {
|
||||
_showUpdateSnackbar(parsed, 'https://apps.apple.com/app/id6753721284');
|
||||
setState(() {
|
||||
_updateType = UpdateType.appStore;
|
||||
_newVersion = parsed;
|
||||
});
|
||||
} else if (Platform.isWindows) {
|
||||
_showUpdateSnackbar(parsed, 'ms-windows-store://pdp/?productid=9NP42GS03Z26');
|
||||
setState(() {
|
||||
_updateType = UpdateType.appStore;
|
||||
_newVersion = parsed;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showUpdateSnackbar(Version newVersion, String url) {
|
||||
buildToast(
|
||||
context,
|
||||
title: AppLocalizations.current.newVersionAvailableWithVersion(newVersion.toString()),
|
||||
closeTitle: AppLocalizations.current.download,
|
||||
onClose: () {
|
||||
launchUrlString(url);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/widgets/ui/gradient_text.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ColoredTitle extends StatelessWidget {
|
||||
final String text;
|
||||
@@ -7,6 +7,6 @@ class ColoredTitle extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientText(text, style: TextStyle(fontSize: 18)).bold;
|
||||
return GradientText(text, style: TextStyle(fontSize: 22)).bold;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/permissions_list.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
@@ -156,30 +157,14 @@ class _ConnectionMethodState extends State<ConnectionMethod> with WidgetsBinding
|
||||
: ButtonStyle.outline(),
|
||||
leading: Icon(Icons.help_outline),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: widget.instructionLink!)),
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (c) => MarkdownPage(assetPath: widget.instructionLink!),
|
||||
);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context).instructions),
|
||||
),
|
||||
if (widget.showTroubleshooting && widget.instructionLink == null)
|
||||
Button(
|
||||
style: widget.isEnabled && Theme.of(context).brightness == Brightness.light
|
||||
? ButtonStyle.outline().withBorder(border: Border.all(color: Colors.gray.shade500))
|
||||
: ButtonStyle.outline(),
|
||||
leading: Icon(Icons.help_outline),
|
||||
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -200,94 +185,15 @@ Future openPermissionSheet(BuildContext context, List<PlatformRequirement> notDo
|
||||
return openSheet(
|
||||
context: context,
|
||||
draggable: true,
|
||||
builder: (context) => _PermissionList(requirements: notDone),
|
||||
builder: (context) => Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: PermissionList(
|
||||
requirements: notDone,
|
||||
onDone: () {
|
||||
closeSheet(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
position: OverlayPosition.bottom,
|
||||
);
|
||||
}
|
||||
|
||||
class _PermissionList extends StatefulWidget {
|
||||
final List<PlatformRequirement> requirements;
|
||||
const _PermissionList({super.key, required this.requirements});
|
||||
|
||||
@override
|
||||
State<_PermissionList> createState() => _PermissionListState();
|
||||
}
|
||||
|
||||
class _PermissionListState extends State<_PermissionList> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (widget.requirements.isNotEmpty) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
Future.wait(widget.requirements.map((e) => e.getStatus())).then((_) {
|
||||
final allDone = widget.requirements.every((e) => e.status);
|
||||
if (allDone && context.mounted) {
|
||||
closeSheet(context);
|
||||
} else if (context.mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 18,
|
||||
children: [
|
||||
Text(
|
||||
context.i18n.theFollowingPermissionsRequired,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
...widget.requirements.map(
|
||||
(e) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Basic(
|
||||
title: Text(e.name),
|
||||
subtitle: e.description != null ? Text(e.description!) : null,
|
||||
trailing: Button(
|
||||
style: e.status ? ButtonStyle.secondary() : ButtonStyle.primary(),
|
||||
onPressed: e.status
|
||||
? null
|
||||
: () {
|
||||
e
|
||||
.call(context, () {
|
||||
setState(() {});
|
||||
})
|
||||
.then((_) {
|
||||
setState(() {});
|
||||
if (widget.requirements.all((e) => e.status)) {
|
||||
closeSheet(context);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: e.status ? Text(context.i18n.granted) : Text(context.i18n.grant),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
168
lib/widgets/ui/help_button.dart
Normal file
168
lib/widgets/ui/help_button.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/menu.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class HelpButton extends StatelessWidget {
|
||||
final bool isMobile;
|
||||
const HelpButton({super.key, required this.isMobile});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final border = isMobile
|
||||
? BorderRadius.only(topRight: Radius.circular(8), topLeft: Radius.circular(8))
|
||||
: BorderRadius.only(bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8));
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: border,
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return Button(
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
MenuLabel(child: Text(context.i18n.getSupport)),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.reddit_outlined),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://www.reddit.com/r/BikeControl/');
|
||||
},
|
||||
child: Text('Reddit'),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.facebook_outlined),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://www.facebook.com/groups/1892836898778912');
|
||||
},
|
||||
child: Text('Facebook'),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(RadixIcons.githubLogo),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
},
|
||||
child: Text('GitHub'),
|
||||
),
|
||||
if (!kIsWeb) ...[
|
||||
MenuButton(
|
||||
leading: Icon(Icons.email_outlined),
|
||||
child: Text('Mail'),
|
||||
onPressed: (c) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Mail Support'),
|
||||
content: Container(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 16,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).mailSupportExplanation,
|
||||
),
|
||||
...[
|
||||
OutlineButton(
|
||||
leading: Icon(Icons.reddit_outlined),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString('https://www.reddit.com/r/BikeControl/');
|
||||
},
|
||||
child: const Text('Reddit'),
|
||||
),
|
||||
OutlineButton(
|
||||
leading: Icon(Icons.facebook_outlined),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString('https://www.facebook.com/groups/1892836898778912');
|
||||
},
|
||||
child: const Text('Facebook'),
|
||||
),
|
||||
OutlineButton(
|
||||
leading: Icon(RadixIcons.githubLogo),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
},
|
||||
child: const Text('GitHub'),
|
||||
),
|
||||
SecondaryButton(
|
||||
leading: Icon(Icons.mail_outlined),
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
|
||||
final isFromStore = (Platform.isAndroid
|
||||
? isFromPlayStore == true
|
||||
: Platform.isIOS);
|
||||
final suffix = isFromStore ? '' : '-sw';
|
||||
|
||||
String email = Uri.encodeComponent('jonas$suffix@bikecontrol.app');
|
||||
String subject = Uri.encodeComponent(
|
||||
context.i18n.helpRequested(packageInfoValue?.version ?? ''),
|
||||
);
|
||||
final dbg = await debugText();
|
||||
String body = Uri.encodeComponent("""
|
||||
|
||||
$dbg""");
|
||||
Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body");
|
||||
|
||||
launchUrl(mail);
|
||||
},
|
||||
child: const Text('Mail'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
MenuDivider(),
|
||||
MenuLabel(child: Text(context.i18n.instructions)),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.help_outline),
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
onPressed: (c) {
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: Icon(Icons.help_outline),
|
||||
style: ButtonVariance.primary.withBorderRadius(
|
||||
borderRadius: border,
|
||||
hoverBorderRadius: border,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: core.settings.getShowOnboarding() && (kIsWeb || Platform.isAndroid || Platform.isIOS) ? 14 : 0,
|
||||
),
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
126
lib/widgets/ui/permissions_list.dart
Normal file
126
lib/widgets/ui/permissions_list.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/utils/requirements/android.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../../utils/i18n_extension.dart';
|
||||
|
||||
class PermissionList extends StatefulWidget {
|
||||
final VoidCallback onDone;
|
||||
final List<PlatformRequirement> requirements;
|
||||
const PermissionList({super.key, required this.requirements, required this.onDone});
|
||||
|
||||
@override
|
||||
State<PermissionList> createState() => _PermissionListState();
|
||||
}
|
||||
|
||||
class _PermissionListState extends State<PermissionList> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (widget.requirements.isNotEmpty) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
Future.wait(widget.requirements.map((e) => e.getStatus())).then((_) {
|
||||
final allDone = widget.requirements.every((e) => e.status);
|
||||
if (allDone && mounted) {
|
||||
closeSheet(context);
|
||||
} else if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 18,
|
||||
children: [
|
||||
SizedBox(),
|
||||
Center(
|
||||
child: Text(
|
||||
context.i18n.theFollowingPermissionsRequired,
|
||||
textAlign: TextAlign.center,
|
||||
).muted.small,
|
||||
),
|
||||
...widget.requirements.map(
|
||||
(e) {
|
||||
final onPressed = e.status
|
||||
? null
|
||||
: () {
|
||||
e
|
||||
.call(context, () {
|
||||
setState(() {});
|
||||
})
|
||||
.then((_) {
|
||||
setState(() {});
|
||||
if (widget.requirements.all((e) => e.status)) {
|
||||
widget.onDone();
|
||||
}
|
||||
});
|
||||
};
|
||||
final optional = e is NotificationRequirement && (Platform.isMacOS || Platform.isIOS);
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
onPressed: onPressed,
|
||||
style: ButtonStyle.card().withBackgroundColor(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.95),
|
||||
),
|
||||
child: Basic(
|
||||
leading: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.primaryForeground,
|
||||
),
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(e.icon),
|
||||
),
|
||||
title: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(child: Text(e.name)),
|
||||
Button(
|
||||
style: e.status
|
||||
? ButtonStyle.secondary(size: ButtonSize.small)
|
||||
: ButtonStyle.primary(size: ButtonSize.small),
|
||||
onPressed: onPressed,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
e.status ? Text(context.i18n.granted) : Text(context.i18n.grant),
|
||||
if (optional) Text('Optional', style: TextStyle(fontSize: 10)).muted,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: e.description != null ? Text(e.description!) : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,32 @@ class Warning extends StatelessWidget {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: (important ? Theme.of(context).colorScheme.destructive : Theme.of(context).colorScheme.secondary)
|
||||
.withAlpha(80),
|
||||
color: (important
|
||||
? Theme.of(context).colorScheme.destructive.withAlpha(30)
|
||||
: Theme.of(context).colorScheme.secondary.withAlpha(80)),
|
||||
border: Border.all(
|
||||
color: important ? Theme.of(context).colorScheme.destructive : Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: children.map((e) => e.small).toList(),
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (important)
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,172 @@
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class SmoothWifiAnimation extends StatefulWidget {
|
||||
const SmoothWifiAnimation({super.key});
|
||||
const SmoothWifiAnimation({
|
||||
super.key,
|
||||
this.size = 160,
|
||||
this.label = 'SCANNING',
|
||||
});
|
||||
|
||||
final double size;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
State<SmoothWifiAnimation> createState() => _SmoothWifiAnimationState();
|
||||
State<SmoothWifiAnimation> createState() => _ScanningIndicatorState();
|
||||
}
|
||||
|
||||
class _SmoothWifiAnimationState extends State<SmoothWifiAnimation> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
final _animationIcons = [
|
||||
Icons.wifi,
|
||||
Icons.wifi_1_bar,
|
||||
Icons.wifi_2_bar,
|
||||
];
|
||||
|
||||
int _currentIndex = 0;
|
||||
class _ScanningIndicatorState extends State<SmoothWifiAnimation> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller =
|
||||
AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
)..addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
_controller.reverse();
|
||||
} else if (status == AnimationStatus.dismissed) {
|
||||
_currentIndex = (_currentIndex + 1) % _animationIcons.length;
|
||||
setState(() {});
|
||||
_controller.forward();
|
||||
}
|
||||
});
|
||||
|
||||
_controller.forward();
|
||||
_c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1600),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
|
||||
child: Icon(
|
||||
_animationIcons[_currentIndex],
|
||||
color: Theme.of(context).colorScheme.cardForeground,
|
||||
key: ValueKey(_currentIndex),
|
||||
size: 26,
|
||||
final s = widget.size;
|
||||
|
||||
// Colors close to the screenshot
|
||||
const ringColor = Color(0xFFBFEFF2); // pale cyan
|
||||
const innerBorder = Color(0xFFE6E6E6);
|
||||
const iconColor = BKColor.main; // teal
|
||||
|
||||
return SizedBox(
|
||||
width: s,
|
||||
height: s + 28, // room for the pill label
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Pulsing outer halo (two staggered ripples looks nicer)
|
||||
_Ripple(
|
||||
controller: _c,
|
||||
color: ringColor,
|
||||
beginScale: 0.80,
|
||||
endScale: 1.15,
|
||||
opacityCurve: Curves.easeOut,
|
||||
interval: const Interval(0.0, 1.0),
|
||||
),
|
||||
_Ripple(
|
||||
controller: _c,
|
||||
color: ringColor,
|
||||
beginScale: 0.70,
|
||||
endScale: 1.05,
|
||||
opacityCurve: Curves.easeOut,
|
||||
interval: const Interval(0.35, 1.0),
|
||||
),
|
||||
|
||||
// Static ring + inner circle
|
||||
Container(
|
||||
width: s,
|
||||
height: s,
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
width: s * 0.62,
|
||||
height: s * 0.62,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white,
|
||||
border: Border.all(color: innerBorder, width: 1.5),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(Icons.wifi_tethering, color: iconColor, size: 40),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom pill label
|
||||
Positioned(
|
||||
bottom: 26,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: ShapeDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: StadiumBorder(),
|
||||
shadows: [
|
||||
BoxShadow(
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 3),
|
||||
color: Color(0x33000000),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primaryForeground,
|
||||
fontSize: 10,
|
||||
letterSpacing: 2.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Ripple extends StatelessWidget {
|
||||
const _Ripple({
|
||||
required this.controller,
|
||||
required this.color,
|
||||
required this.beginScale,
|
||||
required this.endScale,
|
||||
required this.opacityCurve,
|
||||
required this.interval,
|
||||
});
|
||||
|
||||
final AnimationController controller;
|
||||
final Color color;
|
||||
final double beginScale;
|
||||
final double endScale;
|
||||
final Curve opacityCurve;
|
||||
final Interval interval;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = CurvedAnimation(parent: controller, curve: interval);
|
||||
|
||||
// Scale 0..1 -> beginScale..endScale
|
||||
final scale = Tween<double>(begin: beginScale, end: endScale).animate(
|
||||
CurvedAnimation(parent: t, curve: Curves.easeOutCubic),
|
||||
);
|
||||
|
||||
// Opacity starts visible and fades out
|
||||
final opacity = Tween<double>(begin: 0.35, end: 0.0).animate(
|
||||
CurvedAnimation(parent: t, curve: opacityCurve),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (_, __) {
|
||||
return Transform.scale(
|
||||
scale: scale.value,
|
||||
child: Opacity(
|
||||
opacity: opacity.value,
|
||||
child: Container(
|
||||
width: 160,
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color, width: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
|
||||
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.1+60
|
||||
version: 4.2.4+71
|
||||
|
||||
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
|
||||
@@ -97,6 +99,7 @@ flutter:
|
||||
- INSTRUCTIONS_REMOTE_CONTROL.md
|
||||
- INSTRUCTIONS_ROUVY.md
|
||||
- INSTRUCTIONS_ZWIFT.md
|
||||
- INSTRUCTIONS_LOCAL.md
|
||||
- shorebird.yaml
|
||||
- icon.png
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ Future<void> main() async {
|
||||
required Widget child,
|
||||
}) => CustomFrame(
|
||||
platform: size.type,
|
||||
title: 'BikeControl connects to any controller',
|
||||
title: 'Control your favorite trainer app using ANY controller',
|
||||
device: device,
|
||||
child: child,
|
||||
),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:bike_control/bluetooth/devices/shimano/shimano_di2.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/openbikecontrol.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
@@ -38,5 +39,37 @@ Future<void> main() async {
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, false);
|
||||
});
|
||||
|
||||
test('should transmit all events', () async {
|
||||
final instance = ShimanoDi2(BleDevice(name: 'Di2', deviceId: ''));
|
||||
await instance.processCharacteristic(
|
||||
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
|
||||
Uint8List.fromList([0x21, 0x13, 0xF0, 0xF0]),
|
||||
);
|
||||
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
await instance.processCharacteristic(
|
||||
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
|
||||
Uint8List.fromList([0x21, 0x13, 0xF0, 0xF0]),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
await instance.processCharacteristic(
|
||||
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
|
||||
Uint8List.fromList([0x21, 0x14, 0xF0, 0xF0]),
|
||||
);
|
||||
final button = ControllerButton('D-Fly Channel 1');
|
||||
expect(stubActions.performedActions, equals([(button, true, false), (button, false, true)]));
|
||||
|
||||
await instance.processCharacteristic(
|
||||
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
|
||||
Uint8List.fromList([0x21, 0x15, 0xF0, 0xF0]),
|
||||
);
|
||||
expect(
|
||||
stubActions.performedActions,
|
||||
equals([(button, true, false), (button, false, true), (button, true, false), (button, false, true)]),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
class Trial {
|
||||
final bool isTrial;
|
||||
final int remainingDays;
|
||||
final String remainingDays;
|
||||
final bool isActive;
|
||||
final bool isTrialOwnedByThisUser;
|
||||
|
||||
Trial({required this.isTrial, required this.remainingDays});
|
||||
Trial({
|
||||
required this.isTrial,
|
||||
required this.remainingDays,
|
||||
required this.isActive,
|
||||
required this.isTrialOwnedByThisUser,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Trial{isTrial: $isTrial, remainingDays: $remainingDays, isActive: $isActive, isTrialOwnedByThisUser: $isTrialOwnedByThisUser}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ const _escapeMap = {
|
||||
};
|
||||
|
||||
/// A [RegExp] that matches whitespace characters that should be escaped.
|
||||
var _escapeRegExp = RegExp('[\\x00-\\x07\\x0E-\\x1F${_escapeMap.keys.map(_getHexLiteral).join()}]');
|
||||
var _escapeRegExp = RegExp(
|
||||
'[\\x00-\\x07\\x0E-\\x1F${_escapeMap.keys.map(_getHexLiteral).join()}]');
|
||||
|
||||
/// Returns [str] with all whitespace characters represented as their escape
|
||||
/// sequences.
|
||||
@@ -51,7 +52,8 @@ class MethodChannelWindowsIap extends WindowsIapPlatform {
|
||||
|
||||
@override
|
||||
Future<StorePurchaseStatus?> makePurchase(String storeId) async {
|
||||
final result = await methodChannel.invokeMethod<int>('makePurchase', {'storeId': storeId});
|
||||
final result = await methodChannel
|
||||
.invokeMethod<int>('makePurchase', {'storeId': storeId});
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -72,9 +74,12 @@ class MethodChannelWindowsIap extends WindowsIapPlatform {
|
||||
|
||||
@override
|
||||
Stream<List<Product>> productsStream() {
|
||||
return const EventChannel('windows_iap_event_products').receiveBroadcastStream().map((event) {
|
||||
return const EventChannel('windows_iap_event_products')
|
||||
.receiveBroadcastStream()
|
||||
.map((event) {
|
||||
if (event is String) {
|
||||
return parseListNotNull(json: jsonDecode(escape(event)), fromJson: Product.fromJson);
|
||||
return parseListNotNull(
|
||||
json: jsonDecode(escape(event)), fromJson: Product.fromJson);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@@ -87,21 +92,28 @@ class MethodChannelWindowsIap extends WindowsIapPlatform {
|
||||
if (result == null) {
|
||||
return [];
|
||||
}
|
||||
return parseListNotNull(json: jsonDecode(escape(result)), fromJson: Product.fromJson);
|
||||
return parseListNotNull(
|
||||
json: jsonDecode(escape(result)), fromJson: Product.fromJson);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> checkPurchase({required String storeId}) async {
|
||||
final result = await methodChannel.invokeMethod<bool>('checkPurchase', {'storeId': storeId});
|
||||
final result = await methodChannel
|
||||
.invokeMethod<bool>('checkPurchase', {'storeId': storeId});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Trial> getTrialStatusAndRemainingDays() async {
|
||||
final result = await methodChannel.invokeMethod<Map>('getTrialStatusAndRemainingDays');
|
||||
final result =
|
||||
await methodChannel.invokeMethod<Map>('getTrialStatusAndRemainingDays');
|
||||
return Trial(
|
||||
isTrial: result?['isTrial'] as bool? ?? false,
|
||||
remainingDays: result?['remainingDays'] as int? ?? 0,
|
||||
isActive: result?['isActive'] as bool? ?? false,
|
||||
isTrialOwnedByThisUser:
|
||||
result?['isTrialOwnedByThisUser'] as bool? ?? false,
|
||||
remainingDays: result?['remainingDays'] as String? ??
|
||||
DateTime.now().add(Duration(days: 7)).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,6 +123,7 @@ class MethodChannelWindowsIap extends WindowsIapPlatform {
|
||||
if (result == null) {
|
||||
return {};
|
||||
}
|
||||
return result.map((key, value) => MapEntry(key.toString(), StoreLicense.fromJson(jsonDecode(value))));
|
||||
return result.map((key, value) =>
|
||||
MapEntry(key.toString(), StoreLicense.fromJson(jsonDecode(value))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#include <winrt/Windows.Foundation.Collections.h>
|
||||
#include <shobjidl.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <iomanip>
|
||||
#include <flutter/event_sink.h>
|
||||
#include <flutter/event_channel.h>
|
||||
#include <flutter/event_stream_handler.h>
|
||||
@@ -27,54 +29,63 @@ using namespace Windows::Services::Store;
|
||||
using namespace Windows::Foundation::Collections;
|
||||
namespace foundation = Windows::Foundation;
|
||||
|
||||
namespace windows_iap {
|
||||
namespace windows_iap
|
||||
{
|
||||
|
||||
//////////////////////////////////////////////////////////////////////// BEGIN OF MY CODE //////////////////////////////////////////////////////////////
|
||||
flutter::PluginRegistrarWindows* _registrar;
|
||||
flutter::PluginRegistrarWindows *_registrar;
|
||||
|
||||
HWND GetRootWindow(flutter::FlutterView* view) {
|
||||
HWND GetRootWindow(flutter::FlutterView *view)
|
||||
{
|
||||
return ::GetAncestor(view->GetNativeWindow(), GA_ROOT);
|
||||
}
|
||||
|
||||
StoreContext getStore() {
|
||||
StoreContext getStore()
|
||||
{
|
||||
StoreContext store = StoreContext::GetDefault();
|
||||
auto initWindow = store.try_as<IInitializeWithWindow>();
|
||||
if (initWindow != nullptr) {
|
||||
if (initWindow != nullptr)
|
||||
{
|
||||
initWindow->Initialize(GetRootWindow(_registrar->GetView()));
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
std::wstring s2ws(const std::string& s)
|
||||
std::wstring s2ws(const std::string &s)
|
||||
{
|
||||
int len;
|
||||
int slength = (int)s.length() + 1;
|
||||
len = MultiByteToWideChar(CP_ACP, 0, s.c_str(), slength, 0, 0);
|
||||
wchar_t* buf = new wchar_t[len];
|
||||
wchar_t *buf = new wchar_t[len];
|
||||
MultiByteToWideChar(CP_ACP, 0, s.c_str(), slength, buf, len);
|
||||
std::wstring r(buf);
|
||||
delete[] buf;
|
||||
return r;
|
||||
}
|
||||
|
||||
std::string debugString(std::vector<std::string> vt) {
|
||||
std::string debugString(std::vector<std::string> vt)
|
||||
{
|
||||
|
||||
std::stringstream ss;
|
||||
ss << "( ";
|
||||
for (auto t : vt) {
|
||||
for (auto t : vt)
|
||||
{
|
||||
ss << t << ", ";
|
||||
}
|
||||
ss << " )\n";
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
std::string getExtendedErrorString(winrt::hresult error) {
|
||||
std::string getExtendedErrorString(winrt::hresult error)
|
||||
{
|
||||
const HRESULT IAP_E_UNEXPECTED = 0x803f6107L;
|
||||
std::string message;
|
||||
if (error.value == IAP_E_UNEXPECTED) {
|
||||
if (error.value == IAP_E_UNEXPECTED)
|
||||
{
|
||||
message = "This Product has not been properly configured.";
|
||||
}
|
||||
else {
|
||||
else
|
||||
{
|
||||
message = "ExtendedError: " + std::to_string(error.value);
|
||||
}
|
||||
return message;
|
||||
@@ -84,12 +95,14 @@ namespace windows_iap {
|
||||
{
|
||||
StorePurchaseResult result = co_await getStore().RequestPurchaseAsync(storeId);
|
||||
|
||||
if (result.ExtendedError().value != S_OK) {
|
||||
if (result.ExtendedError().value != S_OK)
|
||||
{
|
||||
resultCallback->Error(std::to_string(result.ExtendedError().value), getExtendedErrorString(result.ExtendedError().value));
|
||||
co_return;
|
||||
}
|
||||
int32_t returnCode;
|
||||
switch (result.Status()) {
|
||||
switch (result.Status())
|
||||
{
|
||||
case StorePurchaseStatus::AlreadyPurchased:
|
||||
returnCode = 1;
|
||||
break;
|
||||
@@ -111,7 +124,7 @@ namespace windows_iap {
|
||||
break;
|
||||
|
||||
default:
|
||||
auto status = reinterpret_cast<int32_t*>(result.Status());
|
||||
auto status = reinterpret_cast<int32_t *>(result.Status());
|
||||
resultCallback->Error(std::to_string(*status), "Product was not purchased due to an unknown error.");
|
||||
co_return;
|
||||
break;
|
||||
@@ -120,10 +133,12 @@ namespace windows_iap {
|
||||
resultCallback->Success(flutter::EncodableValue(returnCode));
|
||||
}
|
||||
|
||||
std::string productsToString(std::vector<StoreProduct> products) {
|
||||
std::string productsToString(std::vector<StoreProduct> products)
|
||||
{
|
||||
std::stringstream ss;
|
||||
ss << "[";
|
||||
for (int i = 0; i < products.size(); i++) {
|
||||
for (int i = 0; i < products.size(); i++)
|
||||
{
|
||||
auto product = products.at(i);
|
||||
ss << "{";
|
||||
ss << "\"title\":\"" << to_string(product.Title()) << "\",";
|
||||
@@ -133,7 +148,8 @@ namespace windows_iap {
|
||||
ss << "\"productKind\":\"" << to_string(product.ProductKind()) << "\",";
|
||||
ss << "\"storeId\":\"" << to_string(product.StoreId()) << "\"";
|
||||
ss << "}";
|
||||
if (i != products.size() - 1) {
|
||||
if (i != products.size() - 1)
|
||||
{
|
||||
ss << ",";
|
||||
}
|
||||
}
|
||||
@@ -142,16 +158,19 @@ namespace windows_iap {
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
foundation::IAsyncAction getProducts(std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> resultCallback) {
|
||||
auto result = co_await getStore().GetAssociatedStoreProductsAsync({ L"Consumable", L"Durable", L"UnmanagedConsumable" });
|
||||
if (result.ExtendedError().value != S_OK) {
|
||||
foundation::IAsyncAction getProducts(std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> resultCallback)
|
||||
{
|
||||
auto result = co_await getStore().GetAssociatedStoreProductsAsync({L"Consumable", L"Durable", L"UnmanagedConsumable"});
|
||||
if (result.ExtendedError().value != S_OK)
|
||||
{
|
||||
resultCallback->Error(std::to_string(result.ExtendedError().value), getExtendedErrorString(result.ExtendedError()));
|
||||
}
|
||||
else if (result.Products().Size() == 0) {
|
||||
else if (result.Products().Size() == 0)
|
||||
{
|
||||
resultCallback->Success(flutter::EncodableValue("[]"));
|
||||
|
||||
}
|
||||
else {
|
||||
else
|
||||
{
|
||||
std::vector<StoreProduct> products;
|
||||
for (IKeyValuePair<hstring, StoreProduct> addOn : result.Products())
|
||||
{
|
||||
@@ -163,7 +182,8 @@ namespace windows_iap {
|
||||
}
|
||||
}
|
||||
|
||||
std::string getStoreLicenseString(StoreLicense license) {
|
||||
std::string getStoreLicenseString(StoreLicense license)
|
||||
{
|
||||
std::stringstream ss;
|
||||
ss << "{";
|
||||
ss << "\"isActive\":" << (license.IsActive() ? "true" : "false") << ",";
|
||||
@@ -175,7 +195,8 @@ namespace windows_iap {
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
foundation::IAsyncAction getAddonLicenses(std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> resultCallback) {
|
||||
foundation::IAsyncAction getAddonLicenses(std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> resultCallback)
|
||||
{
|
||||
auto result = co_await getStore().GetAppLicenseAsync();
|
||||
auto addonLicenses = result.AddOnLicenses();
|
||||
|
||||
@@ -192,10 +213,12 @@ namespace windows_iap {
|
||||
/// <summary>
|
||||
/// need to test in real app on store
|
||||
/// </summary>
|
||||
foundation::IAsyncAction checkPurchase(std::string storeId, std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> resultCallback) {
|
||||
foundation::IAsyncAction checkPurchase(std::string storeId, std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> resultCallback)
|
||||
{
|
||||
auto result = co_await getStore().GetAppLicenseAsync();
|
||||
|
||||
if (result.IsActive()) {
|
||||
if (result.IsActive())
|
||||
{
|
||||
|
||||
auto addonLicenses = result.AddOnLicenses();
|
||||
|
||||
@@ -203,69 +226,93 @@ namespace windows_iap {
|
||||
{
|
||||
StoreLicense license = addonLicense.Value();
|
||||
|
||||
if (storeId.compare("") == 0) {
|
||||
if (storeId.compare("") == 0)
|
||||
{
|
||||
// Truong hop storeId empty => bat ky Add-on nao co IsActive = true deu return true
|
||||
if (license.IsActive()) {
|
||||
if (license.IsActive())
|
||||
{
|
||||
resultCallback->Success(flutter::EncodableValue(true));
|
||||
co_return;
|
||||
}
|
||||
}
|
||||
else {
|
||||
else
|
||||
{
|
||||
// Truong hop storeId not empty => check key = storeId
|
||||
auto key = to_string(addonLicense.Key());
|
||||
if (key.compare(storeId) == 0) {
|
||||
if (key.compare(storeId) == 0)
|
||||
{
|
||||
resultCallback->Success(flutter::EncodableValue(license.IsActive()));
|
||||
co_return;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// truong hop duyet het add-on license nhung vang khong tim thay IsActive = true thi return false
|
||||
resultCallback->Success(flutter::EncodableValue(false));
|
||||
}
|
||||
else {
|
||||
else
|
||||
{
|
||||
resultCallback->Success(flutter::EncodableValue(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// need to test in real app on store
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// need to test in real app on store
|
||||
/// </summary>
|
||||
foundation::IAsyncAction getTrialStatusAndRemainingDays(
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> resultCallback)
|
||||
{
|
||||
auto license = co_await getStore().GetAppLicenseAsync();
|
||||
/// <summary>
|
||||
/// need to test in real app on store
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// need to test in real app on store
|
||||
/// </summary>
|
||||
foundation::IAsyncAction getTrialStatusAndRemainingDays(
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> resultCallback)
|
||||
{
|
||||
auto store = getStore();
|
||||
auto license = co_await store.GetAppLicenseAsync();
|
||||
|
||||
flutter::EncodableMap result;
|
||||
result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(false);
|
||||
result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue(0);
|
||||
flutter::EncodableMap result;
|
||||
result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(true);
|
||||
result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue("");
|
||||
result[flutter::EncodableValue("isActive")] = flutter::EncodableValue(license.IsActive());
|
||||
result[flutter::EncodableValue("isTrialOwnedByThisUser")] = flutter::EncodableValue(license.IsTrialOwnedByThisUser());
|
||||
|
||||
|
||||
if (!license.IsActive()) {
|
||||
resultCallback->Success(flutter::EncodableValue(result));
|
||||
co_return;
|
||||
}
|
||||
if (!license.IsActive())
|
||||
{
|
||||
resultCallback->Success(flutter::EncodableValue(result));
|
||||
co_return;
|
||||
}
|
||||
|
||||
if (license.IsTrial()) {
|
||||
result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(true);
|
||||
if (license.IsTrial())
|
||||
{
|
||||
result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(true);
|
||||
|
||||
winrt::Windows::Foundation::TimeSpan expiration = license.TrialTimeRemaining();
|
||||
const auto inDays = std::chrono::duration_cast<std::chrono::hours>(expiration).count() / 24.0;
|
||||
|
||||
result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue(inDays);
|
||||
}
|
||||
auto expirationDate = license.ExpirationDate();
|
||||
|
||||
resultCallback->Success(flutter::EncodableValue(result));
|
||||
}
|
||||
// dt is your winrt::Windows::Foundation::DateTime
|
||||
std::time_t t = winrt::clock::to_time_t(expirationDate); // Convert to time_t (UTC seconds since 1970)
|
||||
std::tm tm_buf;
|
||||
localtime_s(&tm_buf, &t); // Safe version
|
||||
|
||||
std::wstringstream wss;
|
||||
wss << std::put_time(&tm_buf, L"%Y-%m-%d %H:%M:%S"); // Custom format
|
||||
|
||||
//////////////////////////////////////////////////////////////////////// END OF MY CODE //////////////////////////////////////////////////////////////
|
||||
winrt::hstring readable = winrt::hstring{ wss.str() };
|
||||
std::string utf8 = winrt::to_string(readable); // Converts hstring to UTF-8 std::string
|
||||
|
||||
// static
|
||||
result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue(utf8);
|
||||
}
|
||||
else {
|
||||
result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(false);
|
||||
|
||||
}
|
||||
|
||||
resultCallback->Success(flutter::EncodableValue(result));
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////// END OF MY CODE //////////////////////////////////////////////////////////////
|
||||
|
||||
// static
|
||||
void WindowsIapPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarWindows* registrar) {
|
||||
flutter::PluginRegistrarWindows *registrar)
|
||||
{
|
||||
_registrar = registrar;
|
||||
|
||||
auto channel =
|
||||
@@ -276,9 +323,10 @@ namespace windows_iap {
|
||||
auto plugin = std::make_unique<WindowsIapPlugin>();
|
||||
|
||||
channel->SetMethodCallHandler(
|
||||
[plugin_pointer = plugin.get()](const auto& call, auto result) {
|
||||
plugin_pointer->HandleMethodCall(call, std::move(result));
|
||||
});
|
||||
[plugin_pointer = plugin.get()](const auto &call, auto result)
|
||||
{
|
||||
plugin_pointer->HandleMethodCall(call, std::move(result));
|
||||
});
|
||||
|
||||
registrar->AddPlugin(std::move(plugin));
|
||||
}
|
||||
@@ -288,30 +336,37 @@ namespace windows_iap {
|
||||
WindowsIapPlugin::~WindowsIapPlugin() {}
|
||||
|
||||
void WindowsIapPlugin::HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
if (method_call.method_name().compare("makePurchase") == 0) {
|
||||
const flutter::MethodCall<flutter::EncodableValue> &method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result)
|
||||
{
|
||||
if (method_call.method_name().compare("makePurchase") == 0)
|
||||
{
|
||||
auto args = std::get<flutter::EncodableMap>(*method_call.arguments());
|
||||
auto storeId = std::get<std::string>(args[flutter::EncodableValue("storeId")]);
|
||||
makePurchase(to_hstring(storeId), std::move(result));
|
||||
}
|
||||
else if (method_call.method_name().compare("getProducts") == 0) {
|
||||
else if (method_call.method_name().compare("getProducts") == 0)
|
||||
{
|
||||
getProducts(std::move(result));
|
||||
}
|
||||
else if (method_call.method_name().compare("checkPurchase") == 0) {
|
||||
else if (method_call.method_name().compare("checkPurchase") == 0)
|
||||
{
|
||||
auto args = std::get<flutter::EncodableMap>(*method_call.arguments());
|
||||
auto storeId = std::get<std::string>(args[flutter::EncodableValue("storeId")]);
|
||||
checkPurchase(storeId, std::move(result));
|
||||
}
|
||||
else if (method_call.method_name().compare("getAddonLicenses") == 0) {
|
||||
else if (method_call.method_name().compare("getAddonLicenses") == 0)
|
||||
{
|
||||
getAddonLicenses(std::move(result));
|
||||
}
|
||||
else if (method_call.method_name().compare("getTrialStatusAndRemainingDays") == 0) {
|
||||
getTrialStatusAndRemainingDays(std::move(result));
|
||||
else if (method_call.method_name().compare("getTrialStatusAndRemainingDays") == 0)
|
||||
{
|
||||
getTrialStatusAndRemainingDays(std::move(result));
|
||||
}
|
||||
else {
|
||||
else
|
||||
{
|
||||
result->NotImplemented();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace windows_iap
|
||||
} // namespace windows_iap
|
||||
|
||||
Reference in New Issue
Block a user