mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e72af527 | ||
|
|
1692c584f4 | ||
|
|
ab8e1b6afe | ||
|
|
9a7c4bb13a | ||
|
|
b97298099c | ||
|
|
a7a91e00a2 | ||
|
|
ad7d8276a2 | ||
|
|
6d652ad70b | ||
|
|
94d5b4ad92 | ||
|
|
792d2733ff | ||
|
|
07e4b5e89f | ||
|
|
fe271038cb | ||
|
|
51cb4859e3 | ||
|
|
b4667ca894 | ||
|
|
f79cfb6319 | ||
|
|
97279b7c16 | ||
|
|
5b8a64c356 | ||
|
|
29777d86e0 | ||
|
|
58440148b3 | ||
|
|
bfcf43d428 | ||
|
|
b8448d57e5 | ||
|
|
000104365c | ||
|
|
8141310f7b | ||
|
|
f432d9d187 | ||
|
|
5ab8ccc8c8 | ||
|
|
fd9818707e | ||
|
|
7422ff8624 | ||
|
|
58cf7b5bf3 | ||
|
|
87d71e1213 | ||
|
|
a0cde5352f | ||
|
|
b6ed1c047d | ||
|
|
8d8cb7381a | ||
|
|
f3bbf5e06c | ||
|
|
a5f9b42e6f | ||
|
|
d053101c14 | ||
|
|
3022b15d3a | ||
|
|
6b541e1d14 | ||
|
|
586b148ed3 | ||
|
|
cf47a758ee | ||
|
|
89230815a2 | ||
|
|
60a176bba2 | ||
|
|
44b5c010ba | ||
|
|
425c65528b | ||
|
|
8ac6f58d8e | ||
|
|
f6ac724c60 | ||
|
|
9010346c0b | ||
|
|
41a3a8f14d | ||
|
|
a883abcd1c | ||
|
|
ab37de8f40 | ||
|
|
ac0e15eaa7 | ||
|
|
a6a7e7f0c2 | ||
|
|
3cacdf9a3a | ||
|
|
3ebbda3690 | ||
|
|
74abb13acf | ||
|
|
86addc00fd | ||
|
|
9cebea225c | ||
|
|
59bdb30321 | ||
|
|
d51fb7dfa2 | ||
|
|
b955c51a91 | ||
|
|
86ecd1ad20 | ||
|
|
c089b3bdbd | ||
|
|
9612b213aa | ||
|
|
83c9b52708 | ||
|
|
a7bde7c08a | ||
|
|
c8613b5975 | ||
|
|
87bb728601 | ||
|
|
e1f9d4fb08 | ||
|
|
14e6c1186c | ||
|
|
abeb142f0b | ||
|
|
d416756614 | ||
|
|
823eb9e9a4 | ||
|
|
6579092f4a | ||
|
|
c242c09025 | ||
|
|
89c9ed598c | ||
|
|
a3a592bd16 | ||
|
|
a161829913 | ||
|
|
b4473ad067 | ||
|
|
4752f99fcf | ||
|
|
6e757cf15c | ||
|
|
a87810db88 | ||
|
|
0ddb3e8081 | ||
|
|
e29aed8bcf | ||
|
|
d99a3257af | ||
|
|
a772b210cd | ||
|
|
860700ab91 | ||
|
|
ff5d90d468 | ||
|
|
43773310d5 | ||
|
|
2da65645b0 | ||
|
|
0c62d64987 | ||
|
|
546f6c2f8f | ||
|
|
a6ee15e3ba | ||
|
|
51793847cf | ||
|
|
f308aa3847 | ||
|
|
695b994577 | ||
|
|
2301d04c61 | ||
|
|
e46ec5172c | ||
|
|
691e108c82 | ||
|
|
166146a8f8 | ||
|
|
2a42cfc80f | ||
|
|
aff1f20ebe | ||
|
|
804fed799d | ||
|
|
14a4234583 | ||
|
|
72cd165992 | ||
|
|
239aec2083 | ||
|
|
a43ab60dcb | ||
|
|
484b7f74e6 | ||
|
|
8c1ddcb019 | ||
|
|
306a5badef | ||
|
|
e82003cd57 | ||
|
|
6a6aafe0a9 | ||
|
|
4bd440e167 | ||
|
|
80d198787f | ||
|
|
7f7bae477b |
64
.github/workflows/build.yml
vendored
64
.github/workflows/build.yml
vendored
@@ -36,7 +36,7 @@ on:
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
FLUTTER_VERSION: 3.38.4
|
||||
FLUTTER_VERSION: 3.38.7
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -133,22 +133,7 @@ jobs:
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: android
|
||||
args: "--artifact=apk -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}"
|
||||
|
||||
- name: Build Web
|
||||
if: inputs.build_web
|
||||
run: flutter build web --release --base-href "/swiftcontrol/"
|
||||
|
||||
- name: Upload static files as artifact
|
||||
if: inputs.build_web
|
||||
id: deployment
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: build/web
|
||||
|
||||
- name: Web Deploy
|
||||
if: inputs.build_web
|
||||
uses: actions/deploy-pages@v4
|
||||
args: "-- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}"
|
||||
|
||||
- name: Extract latest changelog
|
||||
id: changelog
|
||||
@@ -207,41 +192,6 @@ jobs:
|
||||
run: |
|
||||
xcrun altool --upload-app -f build/ios/ipa/swift_play.ipa -t ios --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
|
||||
- name: Handle Android archives
|
||||
if: inputs.build_android && inputs.build_github && false
|
||||
run: |
|
||||
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/BikeControl.android.apk
|
||||
|
||||
- name: Code Signing of macOS app
|
||||
if: inputs.build_mac && inputs.build_github && false
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime BikeControl.app -v
|
||||
working-directory: build/macos/Build/Products/Release
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
|
||||
- name: Handle macOS archives
|
||||
if: inputs.build_mac && inputs.build_github && false
|
||||
run: |
|
||||
cd build/macos/Build/Products/Release/
|
||||
zip -r BikeControl.macos.zip BikeControl.app/
|
||||
|
||||
- name: Upload Android Artifacts
|
||||
if: inputs.build_android && inputs.build_github && false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/app/outputs/flutter-apk/BikeControl.android.apk
|
||||
|
||||
- name: Upload macOS Artifacts
|
||||
if: inputs.build_mac && inputs.build_github && false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/macos/Build/Products/Release/BikeControl.macos.zip
|
||||
|
||||
#10 Extract Version
|
||||
- name: Extract version from pubspec.yaml
|
||||
@@ -347,14 +297,4 @@ jobs:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/windows/x64/runner/Release/bike_control.windows.zip
|
||||
build/windows/x64/runner/Release/bike_control.msix
|
||||
|
||||
- name: Update Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/windows/x64/runner/Release/bike_control.msix"
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,3 +1,33 @@
|
||||
### 4.5.0 (22-01-2026)
|
||||
|
||||
**Features**:
|
||||
- Android: simulate additional actions for local connection method (Left, Down, Right, Up, Select, Back, Home, Recent Apps)
|
||||
- control your phone with your controller
|
||||
- control UI within the trainer app (if supported)
|
||||
- BikeControl now supports individual mapping when you use more than one Cycplus BC2 and ThinkRider VS200 controller
|
||||
- Windows & macOS: allow configuration of volume keys on Bluetooth HID devices
|
||||
|
||||
### 4.4.0 (16-01-2026)
|
||||
|
||||
**Features**:
|
||||
- Support for Thinkrider VS200
|
||||
|
||||
**Fixes**:
|
||||
- Android: Local connection method allows passing keyboard events to the trainer app
|
||||
- macOS: Compatibility with macOS Tahoe
|
||||
- Windows: send keyboard events to the correct window when using multiple monitors or when another app is focused
|
||||
- Windows: fix media key detection
|
||||
|
||||
### 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!
|
||||
|
||||
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,30 +1,38 @@
|
||||
## Instructions for using the MyWhoosh "Link" connection method
|
||||
*
|
||||
1) launch MyWhoosh on the device of your choice
|
||||
2) OPTIONAL: launch MyWhoosh Link, confirm the "Link" connection works at all. **close MyWhoosh Link** - very important!
|
||||
2) make sure the "MyWhoosh Link" app is not active at the same time as BikeControl
|
||||
3) open BikeControl, follow the on-screen instructions
|
||||
|
||||
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:
|
||||
Here's a video with a few explanations. Note it uses an older version, but the idea is the same.
|
||||
|
||||
[](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
|
||||
## The MyWhoosh Link app itself works fine, but BikeControl doesn't connect
|
||||
*
|
||||
The MyWhoosh Link app must not run simultaneously with BikeControl. Make sure the MyWhoosh Link app is fully closed, then reopen BikeControl and try connecting again.
|
||||
|
||||
## MyWhoosh "Link" method never connects
|
||||
*
|
||||
The same network restrictions apply for BikeControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if a connection is possible at all.
|
||||
Here are some instructions that can help:
|
||||
This is a network/local-discovery problem. BikeControl needs the same kind of local network access as MyWhoosh Link.
|
||||
|
||||
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
|
||||
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
|
||||
Checklist:
|
||||
- Use the MyWhoosh Link app to confirm if "Link" works in general
|
||||
- Use MyWhoosh Link app and connect, then close it, then open up BikeControl - this is key for some users
|
||||
- Both devices are on the **same Wi‑Fi SSID**
|
||||
- Avoid “Guest” networks
|
||||
- Avoid “extenders/mesh guest mode” and networks with device isolation
|
||||
- If your router has it, disable:
|
||||
- “AP isolation / client isolation”
|
||||
- Try moving both devices to the same band:
|
||||
- Prefer **2.4 GHz** (often more reliable for local discovery than mixed/steering)
|
||||
- Temporarily disable:
|
||||
- VPNs
|
||||
- iCloud Private Relay (if enabled)
|
||||
- “Limit IP Address Tracking” (iOS Wi‑Fi option)
|
||||
- iOS Wi‑Fi settings for that network:
|
||||
- Turn off **Private Wi‑Fi Address**
|
||||
- Turn off **Limit IP Address Tracking**
|
||||
- Mesh networks: may work, but if it doesn’t, test with a simple router or phone hotspot.
|
||||
|
||||
In essence:
|
||||
- your two devices (phone, tablet) need to be on the same WiFi network
|
||||
- on iOS you have to turn off "Private Wi-Fi Address" in the WiFi settings
|
||||
- Limit IP Address Tracking may need to be disabled
|
||||
- mesh networks may not work
|
||||
Official MyWhoosh troubleshooting links:
|
||||
- https://mywhoosh.com/troubleshoot/
|
||||
- https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/
|
||||
|
||||
@@ -31,7 +31,7 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
|
||||
## Supported Apps
|
||||
- MyWhoosh
|
||||
- Zwift
|
||||
- TrainingPeaks Virtual / indieVelo
|
||||
- TrainingPeaks Virtual
|
||||
- Biketerra.com
|
||||
- Rouvy
|
||||
- [OpenBikeControl](https://openbikecontrol.org) compatible apps
|
||||
@@ -51,6 +51,7 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
|
||||
- Wahoo Kickr Bike Shift
|
||||
- Wahoo Kickr Bike Pro
|
||||
- CYCPLUS BC2 Virtual Shifter
|
||||
- Thinkrider VS200 Virtual Shifter (beta)
|
||||
- Elite Sterzo Smart (for steering support)
|
||||
- Elite Square Smart Frame (beta)
|
||||
- Your Phone!
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
## Click / Ride device cannot be found
|
||||
*
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
This means BikeControl does NOT see the device via Bluetooth.
|
||||
- Put the controller into pairing mode (LED should blink)
|
||||
- Ensure the controller is NOT connected to another app/device (e.g. Zwift)
|
||||
- Update controller firmware in Zwift Companion, if available
|
||||
- Reboot Bluetooth / reboot phone/PC
|
||||
|
||||
## Click / Ride device does not send any data
|
||||
*
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## My Click v2 disconnects after a minute
|
||||
## My Click v2 disconnects after a minute or buttons do not work
|
||||
*
|
||||
Check [this](https://github.com/jonasbark/swiftcontrol/issues/68) discussion.
|
||||
|
||||
To make your Click V2 work best you should connect it in the Zwift app once each day.
|
||||
To make your Click V2 work best you should connect it in the Zwift app once before a workout session.
|
||||
If you don't do that BikeControl will need to reconnect every minute.
|
||||
|
||||
1. Open Zwift app (not the Companion)
|
||||
2. Log in (subscription not required) and open the device connection screen
|
||||
3. Connect your Trainer, then connect the Click V2
|
||||
4. Optional: some users report that keeping the Click connected for more than a few seconds is more reliable.
|
||||
5. Close the Zwift app again and connect again in BikeControl
|
||||
2. Log in (subscription not required) → device connection screen
|
||||
3. Connect trainer, then connect Click v2
|
||||
4. Keep it connected for ~10–30 seconds
|
||||
5. Close Zwift completely, then connect in BikeControl
|
||||
|
||||
Details/updates: https://github.com/jonasbark/swiftcontrol/issues/68
|
||||
|
||||
## Android: Connection works, buttons work but nothing happens in MyWhoosh and similar
|
||||
*
|
||||
@@ -27,10 +32,6 @@ If you don't do that BikeControl will need to reconnect every minute.
|
||||
- grant accessibility permission for BikeControl
|
||||
- see [https://github.com/jonasbark/swiftcontrol/issues/38](https://github.com/OpenBikeControl/bikecontrol/issues/38) for more details
|
||||
|
||||
## BikeControl crashes on Windows when searching for the device
|
||||
*
|
||||
You're probably running into [this](https://github.com/OpenBikeControl/bikecontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
|
||||
|
||||
|
||||
## My Clicks do not get recognized in MyWhoosh, but I am connected / use local control
|
||||
*
|
||||
|
||||
@@ -1 +1 @@
|
||||
4.2.2
|
||||
4.4.1
|
||||
|
||||
@@ -90,6 +90,23 @@ enum class MediaAction(val raw: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class GlobalAction(val raw: Int) {
|
||||
BACK(0),
|
||||
DPAD_CENTER(1),
|
||||
DOWN(2),
|
||||
RIGHT(3),
|
||||
UP(4),
|
||||
LEFT(5),
|
||||
HOME(6),
|
||||
RECENTS(7);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): GlobalAction? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class WindowEvent (
|
||||
val packageName: String,
|
||||
@@ -175,11 +192,16 @@ private open class AccessibilityApiPigeonCodec : StandardMessageCodec() {
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
GlobalAction.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
WindowEvent.fromList(it)
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
AKeyEvent.fromList(it)
|
||||
}
|
||||
@@ -193,12 +215,16 @@ private open class AccessibilityApiPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
is WindowEvent -> {
|
||||
is GlobalAction -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
is WindowEvent -> {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is AKeyEvent -> {
|
||||
stream.write(131)
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
@@ -213,9 +239,11 @@ interface Accessibility {
|
||||
fun hasPermission(): Boolean
|
||||
fun openPermissions()
|
||||
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
|
||||
fun performGlobalAction(action: GlobalAction)
|
||||
fun controlMedia(action: MediaAction)
|
||||
fun isRunning(): Boolean
|
||||
fun ignoreHidDevices()
|
||||
fun setHandledKeys(keys: List<String>)
|
||||
|
||||
companion object {
|
||||
/** The codec used by Accessibility. */
|
||||
@@ -278,6 +306,24 @@ interface Accessibility {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.performGlobalAction$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val actionArg = args[0] as GlobalAction
|
||||
val wrapped: List<Any?> = try {
|
||||
api.performGlobalAction(actionArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.controlMedia$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
@@ -327,6 +373,24 @@ interface Accessibility {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val keysArg = args[0] as List<String>
|
||||
val wrapped: List<Any?> = try {
|
||||
api.setHandledKeys(keysArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jonasbark.accessibility
|
||||
|
||||
import AKeyEvent
|
||||
import Accessibility
|
||||
import GlobalAction
|
||||
import HidKeyPressedStreamHandler
|
||||
import MediaAction
|
||||
import PigeonEventSink
|
||||
@@ -66,6 +67,10 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
Observable.toService?.performTouch(x = x, y = y, isKeyUp = isKeyUp, isKeyDown = isKeyDown) ?: error("Service not running")
|
||||
}
|
||||
|
||||
override fun performGlobalAction(action: GlobalAction) {
|
||||
Observable.toService?.performGlobalAction(action) ?: error("Service not running")
|
||||
}
|
||||
|
||||
override fun controlMedia(action: MediaAction) {
|
||||
val audioService = context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
|
||||
when (action) {
|
||||
@@ -90,6 +95,11 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
Observable.ignoreHidDevices = true
|
||||
}
|
||||
|
||||
override fun setHandledKeys(keys: List<String>) {
|
||||
// Clear and update the concurrent set
|
||||
Observable.handledKeys = keys.toSet()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class WindowEventListener : StreamEventsStreamHandler(), Receiver {
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.view.KeyEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
|
||||
import GlobalAction
|
||||
|
||||
|
||||
class AccessibilityService : AccessibilityService(), Listener {
|
||||
@@ -70,8 +71,10 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!Observable.ignoreHidDevices && isBleRemote(event)) {
|
||||
// Handle media and volume keys from HID devices here
|
||||
val keyString = KeyEvent.keyCodeToString(event.keyCode)
|
||||
// if currently active app is BikeControl => handle it, so keymap can be created
|
||||
if (!Observable.ignoreHidDevices && isBleRemote(event) && (rootInActiveWindow.packageName == "de.jonasbark.swiftcontrol" || Observable.handledKeys.contains(keyString))) {
|
||||
// Handle keys that have a keymap defined
|
||||
Log.d(
|
||||
"AccessibilityService",
|
||||
"onKeyEvent: keyCode=${event.keyCode} action=${event.action} scanCode=${event.scanCode} flags=${event.flags}"
|
||||
@@ -95,6 +98,20 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
}
|
||||
}
|
||||
|
||||
override fun performGlobalAction(action: GlobalAction) {
|
||||
val mappedAction = when (action) {
|
||||
GlobalAction.BACK -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_BACK
|
||||
GlobalAction.DPAD_CENTER -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_CENTER
|
||||
GlobalAction.DOWN -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_DOWN
|
||||
GlobalAction.RIGHT -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_RIGHT
|
||||
GlobalAction.UP -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_UP
|
||||
GlobalAction.LEFT -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_LEFT
|
||||
GlobalAction.HOME -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_HOME
|
||||
GlobalAction.RECENTS -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_RECENTS
|
||||
}
|
||||
performGlobalAction(mappedAction)
|
||||
}
|
||||
|
||||
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
|
||||
val gestureBuilder = GestureDescription.Builder()
|
||||
val path = Path()
|
||||
|
||||
@@ -2,16 +2,21 @@ package de.jonasbark.accessibility
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.KeyEvent
|
||||
import GlobalAction
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object Observable {
|
||||
var toService: Listener? = null
|
||||
var fromServiceWindow: Receiver? = null
|
||||
var fromServiceKeys: Receiver? = null
|
||||
var ignoreHidDevices: Boolean = false
|
||||
// Use concurrent set for thread-safe access from AccessibilityService and plugin
|
||||
var handledKeys: Set<String> = ConcurrentHashMap.newKeySet()
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
|
||||
fun performGlobalAction(action: GlobalAction)
|
||||
}
|
||||
|
||||
interface Receiver {
|
||||
|
||||
@@ -8,15 +8,30 @@ abstract class Accessibility {
|
||||
|
||||
void performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
|
||||
void performGlobalAction(GlobalAction action);
|
||||
|
||||
void controlMedia(MediaAction action);
|
||||
|
||||
bool isRunning();
|
||||
|
||||
void ignoreHidDevices();
|
||||
|
||||
void setHandledKeys(List<String> keys);
|
||||
}
|
||||
|
||||
enum MediaAction { playPause, next, volumeUp, volumeDown }
|
||||
|
||||
enum GlobalAction {
|
||||
back,
|
||||
dpadCenter,
|
||||
down,
|
||||
right,
|
||||
up,
|
||||
left,
|
||||
home,
|
||||
recents,
|
||||
}
|
||||
|
||||
class WindowEvent {
|
||||
final String packageName;
|
||||
final int top;
|
||||
|
||||
@@ -36,6 +36,17 @@ enum MediaAction {
|
||||
volumeDown,
|
||||
}
|
||||
|
||||
enum GlobalAction {
|
||||
back,
|
||||
dpadCenter,
|
||||
down,
|
||||
right,
|
||||
up,
|
||||
left,
|
||||
home,
|
||||
recents,
|
||||
}
|
||||
|
||||
class WindowEvent {
|
||||
WindowEvent({
|
||||
required this.packageName,
|
||||
@@ -164,11 +175,14 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
} else if (value is MediaAction) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is WindowEvent) {
|
||||
} else if (value is GlobalAction) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is WindowEvent) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is AKeyEvent) {
|
||||
buffer.putUint8(131);
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
@@ -182,8 +196,11 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
final int? value = readValue(buffer) as int?;
|
||||
return value == null ? null : MediaAction.values[value];
|
||||
case 130:
|
||||
return WindowEvent.decode(readValue(buffer)!);
|
||||
final int? value = readValue(buffer) as int?;
|
||||
return value == null ? null : GlobalAction.values[value];
|
||||
case 131:
|
||||
return WindowEvent.decode(readValue(buffer)!);
|
||||
case 132:
|
||||
return AKeyEvent.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
@@ -280,6 +297,29 @@ class Accessibility {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performGlobalAction(GlobalAction action) async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.performGlobalAction$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[action]);
|
||||
final List<Object?>? pigeonVar_replyList =
|
||||
await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> controlMedia(MediaAction action) async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.controlMedia$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
@@ -353,6 +393,29 @@ class Accessibility {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setHandledKeys(List<String> keys) async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[keys]);
|
||||
final List<Object?>? pigeonVar_replyList =
|
||||
await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Stream<WindowEvent> streamEvents( {String instanceName = ''}) {
|
||||
|
||||
@@ -16,7 +16,7 @@ keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
android {
|
||||
namespace = "de.jonasbark.swiftcontrol"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = "27.0.12077973"
|
||||
ndkVersion = "28.2.13676358"
|
||||
|
||||
compileOptions {
|
||||
// Flag to enable support for the new language APIs
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<!-- New Bluetooth permissions in Android 12
|
||||
https://developer.android.com/about/versions/12/features/bluetooth-permissions -->
|
||||
|
||||
BIN
android/app/src/main/res/drawable-hdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-hdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 785 B |
BIN
android/app/src/main/res/drawable-mdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-mdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 476 B |
BIN
android/app/src/main/res/drawable-xhdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-xhdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-xxhdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-xxxhdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
3
android/app/src/main/res/raw/keep.xml
Normal file
3
android/app/src/main/res/raw/keep.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:keep="@drawable/*" />
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
|
||||
@@ -18,7 +18,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ PODS:
|
||||
- flutter_secure_storage_darwin (10.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_volume_controller (0.0.1):
|
||||
- Flutter
|
||||
- gamepads_ios (0.1.1):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
@@ -29,27 +31,24 @@ PODS:
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- purchases_flutter (9.10.2):
|
||||
- purchases_flutter (9.10.6):
|
||||
- Flutter
|
||||
- PurchasesHybridCommon (= 17.25.0)
|
||||
- purchases_ui_flutter (9.10.2):
|
||||
- PurchasesHybridCommon (= 17.27.1)
|
||||
- purchases_ui_flutter (9.10.6):
|
||||
- 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)
|
||||
- PurchasesHybridCommonUI (= 17.27.1)
|
||||
- PurchasesHybridCommon (17.27.1):
|
||||
- RevenueCat (= 5.54.1)
|
||||
- PurchasesHybridCommonUI (17.27.1):
|
||||
- PurchasesHybridCommon (= 17.27.1)
|
||||
- RevenueCatUI (= 5.54.1)
|
||||
- restart_app (0.0.1):
|
||||
- Flutter
|
||||
- RevenueCat (5.51.1)
|
||||
- RevenueCatUI (5.51.1):
|
||||
- RevenueCat (= 5.51.1)
|
||||
- RevenueCat (5.54.1)
|
||||
- RevenueCatUI (5.54.1):
|
||||
- RevenueCat (= 5.54.1)
|
||||
- sensors_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -69,6 +68,7 @@ DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`)
|
||||
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
|
||||
@@ -78,7 +78,6 @@ DEPENDENCIES:
|
||||
- media_key_detector_ios (from `.symlinks/plugins/media_key_detector_ios/ios`)
|
||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||
- 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`)
|
||||
@@ -107,6 +106,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_secure_storage_darwin:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||
flutter_volume_controller:
|
||||
:path: ".symlinks/plugins/flutter_volume_controller/ios"
|
||||
gamepads_ios:
|
||||
:path: ".symlinks/plugins/gamepads_ios/ios"
|
||||
image_picker_ios:
|
||||
@@ -125,8 +126,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/nsd_ios/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
purchases_flutter:
|
||||
@@ -147,13 +146,14 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
bluetooth_low_energy_darwin: 764d8d1ae5abefbcdb839e812b4b25c0061fcf8b
|
||||
bluetooth_low_energy_darwin: 50bc79258e60586e4c4bed5948bd31d925f37fac
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d
|
||||
flutter_volume_controller: e4d5832f08008180f76e30faf671ffd5a425e529
|
||||
gamepads_ios: 1d2930c7a4450a9a1b57444ebf305a6a6cbeea0b
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
|
||||
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
|
||||
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
@@ -161,19 +161,18 @@ SPEC CHECKSUMS:
|
||||
media_key_detector_ios: 7ff9aefdfea00bb7b71e184132381b7d0e7e1269
|
||||
nsd_ios: 8c37babdc6538e3350dbed3a52674d2edde98173
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
purchases_flutter: 7cbb87481a018c1bb8b4966b7d446a7c19d96d87
|
||||
purchases_ui_flutter: 1905cebf3f46e03aeefba0f6258000f3cdd32641
|
||||
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
|
||||
PurchasesHybridCommonUI: 3c1f78addfb3f470713548b4eac8c59254d0efe6
|
||||
purchases_flutter: b3c0792197f69cd7af4c2449b71df6ac6378aace
|
||||
purchases_ui_flutter: caae6d62ea23c6fe964992a28353211cc74b244a
|
||||
PurchasesHybridCommon: 027f03312519c51056457eb2e4f7ee1c91b61b8f
|
||||
PurchasesHybridCommonUI: 48afb5e29204958bff1276b0f7acb8e4b59fe99a
|
||||
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
|
||||
RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0
|
||||
RevenueCatUI: c6acb3648fa58ccb4f0f178556ed0278b796234f
|
||||
RevenueCat: ecbba580fa453b0d4a0475449b904196d74ef678
|
||||
RevenueCatUI: ac7492873928e9e7f297e5e27a7c4f23f9008326
|
||||
sensors_plus: 7229095999f30740798f0eeef5cd120357a8f4f2
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
|
||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
||||
|
||||
PODFILE CHECKSUM: 7ebd5c9b932b3af79d5c67e3af873118b74e970f
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
#include "keypress_simulator_windows_plugin.h"
|
||||
|
||||
// This must be included before many other Windows headers.
|
||||
#include <windows.h>
|
||||
#include <flutter_windows.h>
|
||||
#include <psapi.h>
|
||||
#include <string.h>
|
||||
#include <flutter_windows.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
using flutter::EncodableList;
|
||||
using flutter::EncodableMap;
|
||||
@@ -28,7 +30,8 @@ struct FindWindowData {
|
||||
};
|
||||
|
||||
BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam);
|
||||
HWND FindTargetWindow(const std::string& processName, const std::string& windowTitle);
|
||||
HWND FindTargetWindow(const std::string& processName,
|
||||
const std::string& windowTitle);
|
||||
|
||||
// static
|
||||
void KeypressSimulatorWindowsPlugin::RegisterWithRegistrar(
|
||||
@@ -69,26 +72,91 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
}
|
||||
|
||||
// List of compatible training apps to look for
|
||||
std::vector<std::string> compatibleApps = {
|
||||
"MyWhooshHD.exe",
|
||||
"indieVelo.exe",
|
||||
"biketerra.exe"
|
||||
};
|
||||
std::vector<std::string> compatibleApps = {"MyWhooshHD.exe", "MyWhoosh.exe",
|
||||
"indieVelo.exe", "biketerra.exe",
|
||||
"Rouvy.exe"};
|
||||
|
||||
// Try to find and focus a compatible app
|
||||
// Try to find and focus (or directly target) a compatible app
|
||||
std::string foundProcessName;
|
||||
bool supportsBackgroundInput = true;
|
||||
HWND targetWindow = NULL;
|
||||
for (const std::string& processName : compatibleApps) {
|
||||
targetWindow = FindTargetWindow(processName, "");
|
||||
if (targetWindow != NULL) {
|
||||
// Only focus the window if it's not already in the foreground
|
||||
if (GetForegroundWindow() != targetWindow) {
|
||||
foundProcessName = processName;
|
||||
if (!supportsBackgroundInput && GetForegroundWindow() != targetWindow) {
|
||||
SetForegroundWindow(targetWindow);
|
||||
Sleep(50); // Brief delay to ensure window is focused
|
||||
Sleep(50); // Brief delay to ensure window is focused
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a target window that supports background input and it's not
|
||||
// focused, send messages directly
|
||||
auto postKeyMessage = [](HWND hwnd, UINT vkCode, bool down) {
|
||||
const WORD scanCode =
|
||||
static_cast<WORD>(MapVirtualKey(vkCode, MAPVK_VK_TO_VSC));
|
||||
// Build lParam with repeat count 1 and scan code; set transition states for
|
||||
// key up
|
||||
LPARAM lParam = 1 | (static_cast<LPARAM>(scanCode) << 16);
|
||||
if (vkCode == VK_LEFT || vkCode == VK_RIGHT || vkCode == VK_UP ||
|
||||
vkCode == VK_DOWN || vkCode == VK_INSERT || vkCode == VK_DELETE ||
|
||||
vkCode == VK_HOME || vkCode == VK_END || vkCode == VK_PRIOR ||
|
||||
vkCode == VK_NEXT) {
|
||||
lParam |= (1 << 24); // extended key
|
||||
}
|
||||
if (!down) {
|
||||
lParam |= (1 << 30); // previous key state
|
||||
lParam |= (1 << 31); // transition state
|
||||
}
|
||||
PostMessage(hwnd, down ? WM_KEYDOWN : WM_KEYUP, vkCode, lParam);
|
||||
};
|
||||
|
||||
auto sendKeyToWindow = [&postKeyMessage](HWND hwnd,
|
||||
const std::vector<std::string>& mods,
|
||||
UINT keyCode, bool down) {
|
||||
auto handleModifier = [&postKeyMessage, hwnd](UINT vk, bool press) {
|
||||
postKeyMessage(hwnd, vk, press);
|
||||
};
|
||||
|
||||
if (down) {
|
||||
for (const std::string& modifier : mods) {
|
||||
if (modifier == "shiftModifier") {
|
||||
handleModifier(VK_SHIFT, true);
|
||||
} else if (modifier == "controlModifier") {
|
||||
handleModifier(VK_CONTROL, true);
|
||||
} else if (modifier == "altModifier") {
|
||||
handleModifier(VK_MENU, true);
|
||||
} else if (modifier == "metaModifier") {
|
||||
handleModifier(VK_LWIN, true);
|
||||
}
|
||||
}
|
||||
postKeyMessage(hwnd, keyCode, true);
|
||||
} else {
|
||||
postKeyMessage(hwnd, keyCode, false);
|
||||
// release modifiers
|
||||
for (const std::string& modifier : mods) {
|
||||
if (modifier == "shiftModifier") {
|
||||
handleModifier(VK_SHIFT, false);
|
||||
} else if (modifier == "controlModifier") {
|
||||
handleModifier(VK_CONTROL, false);
|
||||
} else if (modifier == "altModifier") {
|
||||
handleModifier(VK_MENU, false);
|
||||
} else if (modifier == "metaModifier") {
|
||||
handleModifier(VK_LWIN, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (targetWindow != NULL && !foundProcessName.empty() &&
|
||||
supportsBackgroundInput && GetForegroundWindow() != targetWindow) {
|
||||
sendKeyToWindow(targetWindow, modifiers, keyCode, keyDown);
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to send modifier key events
|
||||
auto sendModifierKey = [](UINT vkCode, bool down) {
|
||||
WORD sc = (WORD)MapVirtualKey(vkCode, MAPVK_VK_TO_VSC);
|
||||
@@ -101,7 +169,8 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
};
|
||||
|
||||
// Helper function to process modifiers
|
||||
auto processModifiers = [&sendModifierKey](const std::vector<std::string>& mods, bool down) {
|
||||
auto processModifiers = [&sendModifierKey](
|
||||
const std::vector<std::string>& mods, bool down) {
|
||||
for (const std::string& modifier : mods) {
|
||||
if (modifier == "shiftModifier") {
|
||||
sendModifierKey(VK_SHIFT, down);
|
||||
@@ -125,12 +194,13 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
|
||||
INPUT in = {0};
|
||||
in.type = INPUT_KEYBOARD;
|
||||
in.ki.wVk = 0; // when using SCANCODE, set VK=0
|
||||
in.ki.wVk = 0; // when using SCANCODE, set VK=0
|
||||
in.ki.wScan = sc;
|
||||
in.ki.dwFlags = KEYEVENTF_SCANCODE | (keyDown ? 0 : KEYEVENTF_KEYUP);
|
||||
if (keyCode == VK_LEFT || keyCode == VK_RIGHT || keyCode == VK_UP || keyCode == VK_DOWN ||
|
||||
keyCode == VK_INSERT || keyCode == VK_DELETE || keyCode == VK_HOME || keyCode == VK_END ||
|
||||
keyCode == VK_PRIOR || keyCode == VK_NEXT) {
|
||||
if (keyCode == VK_LEFT || keyCode == VK_RIGHT || keyCode == VK_UP ||
|
||||
keyCode == VK_DOWN || keyCode == VK_INSERT || keyCode == VK_DELETE ||
|
||||
keyCode == VK_HOME || keyCode == VK_END || keyCode == VK_PRIOR ||
|
||||
keyCode == VK_NEXT) {
|
||||
in.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
||||
}
|
||||
SendInput(1, &in, sizeof(INPUT));
|
||||
@@ -149,7 +219,6 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
|
||||
const EncodableMap& args = std::get<EncodableMap>(*method_call.arguments());
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
@@ -157,12 +226,12 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
bool keyDown = std::get<bool>(args.at(EncodableValue("keyDown")));
|
||||
auto it_x = args.find(EncodableValue("x"));
|
||||
if (it_x != args.end() && std::holds_alternative<double>(it_x->second)) {
|
||||
x = std::get<double>(it_x->second);
|
||||
x = std::get<double>(it_x->second);
|
||||
}
|
||||
|
||||
auto it_y = args.find(EncodableValue("y"));
|
||||
if (it_y != args.end() && std::holds_alternative<double>(it_y->second)) {
|
||||
y = std::get<double>(it_y->second);
|
||||
y = std::get<double>(it_y->second);
|
||||
}
|
||||
|
||||
// Get the monitor containing the target point and its DPI
|
||||
@@ -170,7 +239,7 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
|
||||
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
|
||||
double scale_factor = dpi / 96.0;
|
||||
|
||||
|
||||
// Scale the coordinates according to the DPI scaling
|
||||
int scaled_x = static_cast<int>(x * scale_factor);
|
||||
int scaled_y = static_cast<int>(y * scale_factor);
|
||||
@@ -183,14 +252,14 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
input.type = INPUT_MOUSE;
|
||||
|
||||
if (keyDown) {
|
||||
// Mouse left button down
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
// Mouse left button down
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
|
||||
} else {
|
||||
// Mouse left button up
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
// Mouse left button up
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
}
|
||||
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
@@ -201,7 +270,7 @@ BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
|
||||
// Check if window is visible and not minimized
|
||||
if (!IsWindowVisible(hwnd) || IsIconic(hwnd)) {
|
||||
return TRUE; // Continue enumeration
|
||||
return TRUE; // Continue enumeration
|
||||
}
|
||||
|
||||
// Get window title
|
||||
@@ -211,7 +280,8 @@ BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
// Get process name
|
||||
DWORD processId;
|
||||
GetWindowThreadProcessId(hwnd, &processId);
|
||||
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId);
|
||||
HANDLE hProcess =
|
||||
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId);
|
||||
char processName[MAX_PATH];
|
||||
if (hProcess) {
|
||||
DWORD size = sizeof(processName);
|
||||
@@ -219,7 +289,7 @@ BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
// Extract just the filename from the full path
|
||||
char* filename = strrchr(processName, '\\');
|
||||
if (filename) {
|
||||
filename++; // Skip the backslash
|
||||
filename++; // Skip the backslash
|
||||
} else {
|
||||
filename = processName;
|
||||
}
|
||||
@@ -228,7 +298,7 @@ BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
if (!data->targetProcessName.empty() &&
|
||||
_stricmp(filename, data->targetProcessName.c_str()) == 0) {
|
||||
data->foundWindow = hwnd;
|
||||
return FALSE; // Stop enumeration
|
||||
return FALSE; // Stop enumeration
|
||||
}
|
||||
}
|
||||
CloseHandle(hProcess);
|
||||
@@ -238,13 +308,14 @@ BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
if (!data->targetWindowTitle.empty() &&
|
||||
_stricmp(windowTitle, data->targetWindowTitle.c_str()) == 0) {
|
||||
data->foundWindow = hwnd;
|
||||
return FALSE; // Stop enumeration
|
||||
return FALSE; // Stop enumeration
|
||||
}
|
||||
|
||||
return TRUE; // Continue enumeration
|
||||
return TRUE; // Continue enumeration
|
||||
}
|
||||
|
||||
HWND FindTargetWindow(const std::string& processName, const std::string& windowTitle) {
|
||||
HWND FindTargetWindow(const std::string& processName,
|
||||
const std::string& windowTitle) {
|
||||
FindWindowData data;
|
||||
data.targetProcessName = processName;
|
||||
data.targetWindowTitle = windowTitle;
|
||||
@@ -257,19 +328,15 @@ HWND FindTargetWindow(const std::string& processName, const std::string& windowT
|
||||
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")));
|
||||
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}
|
||||
};
|
||||
{"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()) {
|
||||
@@ -282,7 +349,7 @@ void KeypressSimulatorWindowsPlugin::SimulateMediaKey(
|
||||
INPUT inputs[2] = {};
|
||||
inputs[0].type = INPUT_KEYBOARD;
|
||||
inputs[0].ki.wVk = static_cast<WORD>(vkCode);
|
||||
inputs[0].ki.dwFlags = 0; // Key down
|
||||
inputs[0].ki.dwFlags = 0; // Key down
|
||||
|
||||
// Send key up event
|
||||
inputs[1].type = INPUT_KEYBOARD;
|
||||
@@ -298,7 +365,6 @@ void KeypressSimulatorWindowsPlugin::SimulateMediaKey(
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
}
|
||||
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
|
||||
@@ -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);
|
||||
@@ -63,6 +65,10 @@ class Connection {
|
||||
lastLogEntries = lastLogEntries.takeLast(kIsWeb ? 1000 : 60).toList();
|
||||
});
|
||||
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows)) {
|
||||
core.mediaKeyHandler.initialize();
|
||||
}
|
||||
|
||||
UniversalBle.onAvailabilityChange = (available) {
|
||||
_actionStreams.add(BluetoothAvailabilityNotification(available == AvailabilityState.poweredOn));
|
||||
if (available == AvailabilityState.poweredOn && !kIsWeb) {
|
||||
@@ -83,7 +89,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))) {
|
||||
@@ -96,7 +102,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;
|
||||
@@ -123,7 +131,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)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -132,7 +140,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) {
|
||||
@@ -267,11 +275,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();
|
||||
}
|
||||
@@ -281,11 +289,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) {
|
||||
@@ -306,11 +314,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) {
|
||||
@@ -364,8 +372,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
|
||||
|
||||
@@ -17,11 +17,11 @@ import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
final String name;
|
||||
final String? _name;
|
||||
final bool isBeta;
|
||||
final List<ControllerButton> availableButtons;
|
||||
|
||||
BaseDevice(this.name, {required this.availableButtons, this.isBeta = false}) {
|
||||
BaseDevice(this._name, {required this.availableButtons, this.isBeta = false}) {
|
||||
if (availableButtons.isEmpty && core.actionHandler.supportedApp is CustomApp) {
|
||||
// TODO we should verify where the buttons came from
|
||||
final allButtons = core.actionHandler.supportedApp!.keymap.keyPairs.flatMap((e) => e.buttons);
|
||||
@@ -34,17 +34,18 @@ abstract class BaseDevice {
|
||||
Timer? _longPressTimer;
|
||||
Set<ControllerButton> _previouslyPressedButtons = <ControllerButton>{};
|
||||
|
||||
String get name => _name ?? runtimeType.toString();
|
||||
|
||||
@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;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name;
|
||||
}
|
||||
String toString() => name;
|
||||
|
||||
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
|
||||
|
||||
|
||||
@@ -14,13 +14,16 @@ 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';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
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';
|
||||
@@ -29,12 +32,23 @@ import 'package:universal_ble/universal_ble.dart';
|
||||
import 'cycplus/cycplus_bc2.dart';
|
||||
import 'elite/elite_square.dart';
|
||||
import 'elite/elite_sterzo.dart';
|
||||
import 'thinkrider/thinkrider_vs200.dart';
|
||||
|
||||
abstract class BluetoothDevice extends BaseDevice {
|
||||
final BleDevice scanResult;
|
||||
|
||||
BluetoothDevice(this.scanResult, {required super.availableButtons, super.isBeta = false})
|
||||
: super(scanResult.name ?? 'Unknown Device') {
|
||||
BluetoothDevice(
|
||||
this.scanResult, {
|
||||
required List<ControllerButton> availableButtons,
|
||||
bool allowMultiple = false,
|
||||
bool isBeta = false,
|
||||
}) : super(
|
||||
scanResult.name,
|
||||
availableButtons: allowMultiple
|
||||
? availableButtons.map((b) => b.copyWith(sourceDeviceId: scanResult.deviceId)).toList()
|
||||
: availableButtons,
|
||||
isBeta: isBeta,
|
||||
) {
|
||||
rssi = scanResult.rssi;
|
||||
}
|
||||
|
||||
@@ -53,15 +67,24 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
ShimanoDi2Constants.SERVICE_UUID,
|
||||
ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE,
|
||||
OpenBikeControlConstants.SERVICE_UUID,
|
||||
ThinkRiderVs200Constants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
static final List<String> _ignoredNames = ['ASSIOMA', 'QUARQ', 'POWERCRANK'];
|
||||
|
||||
static BluetoothDevice? fromScanResult(BleDevice scanResult) {
|
||||
// skip devices with ignored names
|
||||
if (scanResult.name != null &&
|
||||
_ignoredNames.any((ignoredName) => scanResult.name!.toUpperCase().startsWith(ignoredName))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data
|
||||
BluetoothDevice? device;
|
||||
if (kIsWeb) {
|
||||
device = switch (scanResult.name) {
|
||||
'Zwift Ride' => ZwiftRide(scanResult),
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
'Zwift Play' => ZwiftPlay(scanResult, deviceType: ZwiftDeviceType.playLeft),
|
||||
'Zwift Click' => ZwiftClickV2(scanResult),
|
||||
'SQUARE' => EliteSquare(scanResult),
|
||||
'OpenBike' => OpenBikeControlDevice(scanResult),
|
||||
@@ -72,6 +95,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('THINK VS') => ThinkRiderVs200(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SRAM') => SramAxs(scanResult),
|
||||
_ => null,
|
||||
@@ -81,7 +105,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
null => null,
|
||||
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
|
||||
// https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
//'Zwift Play' => ZwiftPlay(scanResult),
|
||||
//'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller
|
||||
_ when scanResult.name!.toUpperCase().startsWith('HEADWIND') => WahooKickrHeadwind(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SQUARE') => EliteSquare(scanResult),
|
||||
@@ -90,7 +114,8 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase()) => CycplusBc2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('THINK VS') => ThinkRiderVs200(scanResult),
|
||||
//_ when scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase()) => CycplusBc2(scanResult),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID.toLowerCase()) => ShimanoDi2(scanResult),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE.toLowerCase()) => ShimanoDi2(
|
||||
scanResult,
|
||||
@@ -102,6 +127,9 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
OpenBikeControlDevice(scanResult),
|
||||
_ when scanResult.services.contains(WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase()) =>
|
||||
WahooKickrHeadwind(scanResult),
|
||||
_ when scanResult.services.contains(ThinkRiderVs200Constants.SERVICE_UUID.toLowerCase()) => ThinkRiderVs200(
|
||||
scanResult,
|
||||
),
|
||||
// otherwise the service UUIDs will be used
|
||||
_ => null,
|
||||
};
|
||||
@@ -120,28 +148,34 @@ 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, deviceType: type!),
|
||||
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult, deviceType: type!),
|
||||
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 &&
|
||||
core.connection.controllerDevices.none((d) => d is ZwiftRide)) {
|
||||
// Fallback for Zwift Ride if nothing else matched => old firmware
|
||||
if (navigatorKey.currentContext?.mounted ?? false) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: 'You may need to update your Zwift Ride firmware.',
|
||||
duration: Duration(seconds: 6),
|
||||
);
|
||||
}
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -152,11 +186,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 +259,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(),
|
||||
@@ -316,15 +345,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',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ class CycplusBc2 extends BluetoothDevice {
|
||||
CycplusBc2(super.scanResult)
|
||||
: super(
|
||||
availableButtons: CycplusBc2Buttons.values,
|
||||
allowMultiple: true,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -40,7 +41,7 @@ class CycplusBc2 extends BluetoothDevice {
|
||||
// Process index 6 (shift up)
|
||||
final currentByte6 = bytes[6];
|
||||
if (_shouldTriggerShift(currentByte6, _lastStateIndex6)) {
|
||||
buttonsToPress.add(CycplusBc2Buttons.shiftUp);
|
||||
buttonsToPress.add(availableButtons[0]);
|
||||
_lastStateIndex6 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte6, (val) => _lastStateIndex6 = val);
|
||||
@@ -49,7 +50,7 @@ class CycplusBc2 extends BluetoothDevice {
|
||||
// Process index 7 (shift down)
|
||||
final currentByte7 = bytes[7];
|
||||
if (_shouldTriggerShift(currentByte7, _lastStateIndex7)) {
|
||||
buttonsToPress.add(CycplusBc2Buttons.shiftDown);
|
||||
buttonsToPress.add(availableButtons[1]);
|
||||
_lastStateIndex7 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte7, (val) => _lastStateIndex7 = val);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class GamepadDevice extends BaseDevice {
|
||||
final String id;
|
||||
@@ -68,12 +70,20 @@ class GamepadDevice extends BaseDevice {
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
name.screenshot,
|
||||
toString().screenshot,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
],
|
||||
),
|
||||
if (Platform.isAndroid && !core.settings.getLocalEnabled())
|
||||
Warning(
|
||||
children: [
|
||||
Text(
|
||||
'For it to work properly, even when BikeControl is in the background, you need to enable the local connection method in the next tab.',
|
||||
).small,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:io';
|
||||
|
||||
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:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:flutter/material.dart' show PopupMenuButton, PopupMenuItem;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class HidDevice extends BaseDevice {
|
||||
HidDevice(super.name) : super(availableButtons: []);
|
||||
@@ -13,24 +17,36 @@ class HidDevice extends BaseDevice {
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Row(
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: Text(name)),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Ignore'),
|
||||
onTap: () {
|
||||
core.connection.disconnect(this, forget: true, persistForget: true);
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
(core.actionHandler as AndroidActions).ignoreHidDevices();
|
||||
} else if (core.mediaKeyHandler.isMediaKeyDetectionEnabled.value) {
|
||||
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = false;
|
||||
}
|
||||
},
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(toString())),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Ignore'),
|
||||
onTap: () {
|
||||
core.connection.disconnect(this, forget: true, persistForget: true);
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
(core.actionHandler as AndroidActions).ignoreHidDevices();
|
||||
} else if (core.mediaKeyHandler.isMediaKeyDetectionEnabled.value) {
|
||||
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (Platform.isAndroid && !core.settings.getLocalEnabled())
|
||||
Warning(
|
||||
children: [
|
||||
Text(
|
||||
'For it to work properly, even when BikeControl is in the background, you need to enable the local connection method in the next tab.',
|
||||
).small,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
@@ -34,8 +35,8 @@ class OpenBikeProtocolParser {
|
||||
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),
|
||||
0x16: ControllerButton('Menu', identifier: 0x16, action: InGameAction.menu),
|
||||
0x17: ControllerButton('Home', identifier: 0x17, action: InGameAction.home),
|
||||
0x18: ControllerButton('Steer Left', identifier: 0x18, action: InGameAction.steerLeft),
|
||||
0x19: ControllerButton('Steer Right', identifier: 0x19, action: InGameAction.steerRight),
|
||||
// Social/Emotes (0x20-0x2F)
|
||||
|
||||
90
lib/bluetooth/devices/thinkrider/thinkrider_vs200.dart
Normal file
90
lib/bluetooth/devices/thinkrider/thinkrider_vs200.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class ThinkRiderVs200 extends BluetoothDevice {
|
||||
ThinkRiderVs200(super.scanResult)
|
||||
: super(
|
||||
availableButtons: ThinkRiderVs200Buttons.values,
|
||||
isBeta: true,
|
||||
allowMultiple: true,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
// Only subscribe to service 0xFEA0
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ThinkRiderVs200Constants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${ThinkRiderVs200Constants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ThinkRiderVs200Constants.CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${ThinkRiderVs200Constants.CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == ThinkRiderVs200Constants.CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
final hexValue = _bytesToHex(bytes).toLowerCase();
|
||||
|
||||
// Log all received values while in beta
|
||||
if (isBeta) {
|
||||
actionStreamInternal.add(LogNotification('VS200 received: $hexValue'));
|
||||
}
|
||||
|
||||
// Check for specific byte patterns
|
||||
if (hexValue == ThinkRiderVs200Constants.SHIFT_UP_PATTERN) {
|
||||
// Plus button pressed
|
||||
actionStreamInternal.add(LogNotification('Shift Up detected: $hexValue'));
|
||||
handleButtonsClickedWithoutLongPressSupport([availableButtons[0]]);
|
||||
} else if (hexValue == ThinkRiderVs200Constants.SHIFT_DOWN_PATTERN) {
|
||||
// Minus button pressed
|
||||
actionStreamInternal.add(LogNotification('Shift Down detected: $hexValue'));
|
||||
handleButtonsClickedWithoutLongPressSupport([availableButtons[1]]);
|
||||
}
|
||||
}
|
||||
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
String _bytesToHex(List<int> bytes) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
}
|
||||
|
||||
class ThinkRiderVs200Constants {
|
||||
// Service and characteristic UUIDs based on the nRF Connect screenshot
|
||||
static const String SERVICE_UUID = "0000fea0-0000-1000-8000-00805f9b34fb";
|
||||
static const String CHARACTERISTIC_UUID = "0000fea1-0000-1000-8000-00805f9b34fb";
|
||||
|
||||
// Byte patterns for button detection
|
||||
static const String SHIFT_UP_PATTERN = "f3050301fc";
|
||||
static const String SHIFT_DOWN_PATTERN = "f3050300fb";
|
||||
}
|
||||
|
||||
class ThinkRiderVs200Buttons {
|
||||
static const ControllerButton shiftUp = ControllerButton(
|
||||
'shiftUp',
|
||||
action: InGameAction.shiftUp,
|
||||
icon: Icons.add,
|
||||
);
|
||||
|
||||
static const ControllerButton shiftDown = ControllerButton(
|
||||
'shiftDown',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
);
|
||||
|
||||
static const List<ControllerButton> values = [
|
||||
shiftUp,
|
||||
shiftDown,
|
||||
];
|
||||
}
|
||||
@@ -80,6 +80,7 @@ class WahooKickrHeadwind extends BluetoothDevice {
|
||||
service,
|
||||
characteristic,
|
||||
manualModeData,
|
||||
withoutResponse: true,
|
||||
);
|
||||
_currentMode = HeadwindMode.manual;
|
||||
}
|
||||
@@ -93,6 +94,7 @@ class WahooKickrHeadwind extends BluetoothDevice {
|
||||
service,
|
||||
characteristic,
|
||||
data,
|
||||
withoutResponse: true,
|
||||
);
|
||||
_currentSpeed = speedPercent;
|
||||
}
|
||||
@@ -109,6 +111,7 @@ class WahooKickrHeadwind extends BluetoothDevice {
|
||||
service,
|
||||
characteristic,
|
||||
data,
|
||||
withoutResponse: true,
|
||||
);
|
||||
_currentMode = HeadwindMode.heartRate;
|
||||
}
|
||||
|
||||
@@ -59,13 +59,13 @@ class ZwiftButtons {
|
||||
// left controller
|
||||
static const ControllerButton navigationUp = ControllerButton(
|
||||
'navigationUp',
|
||||
action: InGameAction.toggleUi,
|
||||
action: InGameAction.up,
|
||||
icon: Icons.keyboard_arrow_up,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton navigationDown = ControllerButton(
|
||||
'navigationDown',
|
||||
action: InGameAction.uturn,
|
||||
action: InGameAction.down,
|
||||
icon: Icons.keyboard_arrow_down,
|
||||
color: Colors.black,
|
||||
);
|
||||
@@ -106,7 +106,7 @@ class ZwiftButtons {
|
||||
action: InGameAction.rideOnBomb,
|
||||
color: Colors.deepOrangeAccent,
|
||||
);
|
||||
static const ControllerButton y = ControllerButton('y', action: null, color: Colors.lightBlue);
|
||||
static const ControllerButton y = ControllerButton('y', action: InGameAction.menu, color: Colors.lightBlue);
|
||||
static const ControllerButton onOffRight = ControllerButton('onOffRight', action: InGameAction.toggleUi);
|
||||
static const ControllerButton sideButtonRight = ControllerButton('sideButtonRight', action: InGameAction.shiftUp);
|
||||
static const ControllerButton paddleRight = ControllerButton('paddleRight', action: InGameAction.shiftUp);
|
||||
|
||||
@@ -81,6 +81,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
|
||||
'ble-service-uuids': Uint8List.fromList('FC82'.codeUnits),
|
||||
'mac-address': Uint8List.fromList('50-50-25-6C-66-9C'.codeUnits),
|
||||
'serial-number': Uint8List.fromList('244700181'.codeUnits),
|
||||
'manufacturer-data': Uint8List.fromList('094A0BAAAA'.codeUnits),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
@@ -87,10 +89,10 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode) {
|
||||
if (kDebugMode && false) {
|
||||
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,14 +1,14 @@
|
||||
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:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class ZwiftPlay extends ZwiftDevice {
|
||||
ZwiftPlay(super.scanResult)
|
||||
final ZwiftDeviceType deviceType;
|
||||
|
||||
ZwiftPlay(super.scanResult, {required this.deviceType})
|
||||
: super(
|
||||
availableButtons: [
|
||||
ZwiftButtons.y,
|
||||
@@ -34,6 +34,9 @@ class ZwiftPlay extends ZwiftDevice {
|
||||
@override
|
||||
bool get canVibrate => true;
|
||||
|
||||
@override
|
||||
String get name => '${super.name} (${deviceType.name.splitByUpperCase().split(' ').last})';
|
||||
|
||||
@override
|
||||
List<ControllerButton> processClickNotification(Uint8List message) {
|
||||
final status = PlayKeyPadStatus.fromBuffer(message);
|
||||
@@ -62,23 +65,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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl benötigt Zugriffsberechtigungen, um Ihre Trainings-Apps zu steuern.",
|
||||
"accessibilityDisclaimer": "BikeControl greift nur auf Ihren Bildschirm zu, um die von Ihnen konfigurierten Gesten auszuführen. Es werden keine weiteren Bedienungshilfen oder persönlichen Daten abgerufen.",
|
||||
"accessibilityReasonControl": "• Um Ihnen die Steuerung von Apps wie MyWhoosh, IndieVelo und anderen über Ihre Zwift-Geräte zu ermöglichen",
|
||||
"accessibilityReasonControl": "• Um Ihnen die Steuerung von Apps wie MyWhoosh, TrainingPeaks und anderen über Ihre Zwift-Geräte zu ermöglichen",
|
||||
"accessibilityReasonTouch": "• Um Berührungsgesten auf Ihrem Bildschirm zur Steuerung von Trainer-Apps zu simulieren.",
|
||||
"accessibilityReasonWindow": "• Um zu erkennen, welches Trainings-App-Fenster aktuell aktiv ist",
|
||||
"accessibilityServiceExplanation": "BikeControl benötigt die AccessibilityService API von Android, um ordnungsgemäß zu funktionieren.",
|
||||
@@ -23,6 +23,7 @@
|
||||
"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?",
|
||||
"androidSystemAction": "Android-Systemaktion",
|
||||
"appIdActions": "{appId} Aktionen",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
@@ -31,6 +32,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"asAFinalStepYoullChooseHowToConnectTo": "Im letzten Schritt wählen Sie die Verbindungsmethode aus für {trainerApp}",
|
||||
"battery": "Batterie",
|
||||
"beforeDate": "Vor dem {date}",
|
||||
"bluetoothAdvertiseAccess": "Bluetooth-Zugriff",
|
||||
@@ -44,7 +46,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 (nicht Companion!).\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",
|
||||
@@ -65,7 +67,7 @@
|
||||
}
|
||||
},
|
||||
"connection": "Verbindung",
|
||||
"continueAction": "Weitermachen",
|
||||
"continueAction": "Weiter",
|
||||
"controlAppUsingModes": "Steuere {appName} über {modes}",
|
||||
"@controlAppUsingModes": {
|
||||
"placeholders": {
|
||||
@@ -77,6 +79,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",
|
||||
@@ -137,8 +140,9 @@
|
||||
"duplicate": "Duplikat",
|
||||
"enableAutoRotation": "Aktiviere die automatische Drehung auf Ihrem Gerät, um sicherzustellen, dass die App ordnungsgemäß funktioniert.",
|
||||
"enableBluetooth": "Bluetooth aktivieren",
|
||||
"enableItInTheConnectionSettingsFirst": "Aktiviere es zuerst in den Verbindungseinstellungen.",
|
||||
"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": {
|
||||
@@ -146,7 +150,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableLocalConnectionMethodFirst": "Aktivieren Sie zuerst die Methode „Lokale Verbindung“.",
|
||||
"enableMediaKeyDetection": "Medientastenerkennung aktivieren",
|
||||
"enableMywhooshLinkInTheConnectionSettingsFirst": "Aktiviere zuerst MyWhoosh Link in den Verbindungseinstellungen.",
|
||||
"enablePairingProcess": "Kopplungsprozess aktivieren",
|
||||
"enablePermissions": "Berechtigungen aktivieren",
|
||||
"enableSteeringWithPhone": "Sensoren Ihres Telefons aktivieren z.B. zum Lenken",
|
||||
@@ -214,6 +220,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsGetYouSetUp": "Starten wir die Einrichtung",
|
||||
"license": "Lizenz",
|
||||
"licenseStatus": "Lizenzstatus",
|
||||
"loadScreenshotForPlacement": "Lade einen Screenshot aus dem Spiel zur Platzierung hoch.",
|
||||
@@ -239,7 +246,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.",
|
||||
@@ -379,17 +386,19 @@
|
||||
"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",
|
||||
"supportedActions": "Unterstützte Aktionen",
|
||||
"targetOtherDevice": "Anderes Gerät",
|
||||
"targetThisDevice": "Dieses Gerät",
|
||||
"theFollowingPermissionsRequired": "Folgende Berechtigungen sind erforderlich:",
|
||||
@@ -400,10 +409,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": "Eine Freischaltung ist derzeit noch nicht möglich, daher ist die App vorerst uneingeschränkt nutzbar!",
|
||||
"update": "Aktualisieren",
|
||||
"useCustomKeymapForButton": "Verwende eine benutzerdefinierte Tastaturbelegung, um die",
|
||||
"version": "Version {version}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl needs accessibility permission to control your training apps.",
|
||||
"accessibilityDisclaimer": "BikeControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.",
|
||||
"accessibilityReasonControl": "• To enable you to control apps like MyWhoosh, IndieVelo, and others using your Zwift devices",
|
||||
"accessibilityReasonControl": "• To enable you to control apps like MyWhoosh, TrainingPeaks, and others using your Zwift devices",
|
||||
"accessibilityReasonTouch": "• To simulate touch gestures on your screen for controlling trainer apps",
|
||||
"accessibilityReasonWindow": "• To detect which training app window is currently active",
|
||||
"accessibilityServiceExplanation": "BikeControl needs to use Android's AccessibilityService API to function properly.",
|
||||
@@ -23,6 +23,7 @@
|
||||
"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?",
|
||||
"androidSystemAction": "Android System Action",
|
||||
"appIdActions": "{appId} actions",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
@@ -31,6 +32,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"asAFinalStepYoullChooseHowToConnectTo": "As a final step you'll choose how to connect to {trainerApp}.",
|
||||
"battery": "Battery",
|
||||
"beforeDate": "Before {date}",
|
||||
"bluetoothAdvertiseAccess": "Bluetooth Advertise access",
|
||||
@@ -44,7 +46,7 @@
|
||||
"chooseBikeControlInConnectionScreen": "Choose BikeControl in the connection screen.",
|
||||
"clickAButtonOnYourController": "Click a button on your controller to edit its action or tap the edit icon.",
|
||||
"clickV2EventInfo": "Your Click V2 may no longer send button events. Please check by tapping a few buttons and see if they are visible in BikeControl.",
|
||||
"clickV2Instructions": "To make your Zwift Click V2 work best, you should connect it in the Zwift app before each training session.\nIf you don't do that, the Click V2 will stop working after a minute.\n\n1. Open Zwift app\n2. Log in (subscription not required) and open the device connection screen\n3. Connect your Trainer, then connect the Zwift Click V2\n4. Close the Zwift app again and connect again in BikeControl",
|
||||
"clickV2Instructions": "To make your Zwift Click V2 work best, you should connect it in the Zwift app before each training session.\nIf you don't do that, the Click V2 will stop working after a minute.\n\n1. Open Zwift app (not the Companion!)\n2. Log in (subscription not required) and open the device connection screen\n3. Connect your Trainer, then connect the Zwift Click V2\n4. Close the Zwift app again and connect again in BikeControl",
|
||||
"close": "Close",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} commands remaining today",
|
||||
"configuration": "Configuration",
|
||||
@@ -77,6 +79,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",
|
||||
@@ -137,8 +140,9 @@
|
||||
"duplicate": "Duplicate",
|
||||
"enableAutoRotation": "Enable auto-rotation on your device to make sure the app works correctly.",
|
||||
"enableBluetooth": "Enable Bluetooth",
|
||||
"enableItInTheConnectionSettingsFirst": "Enable it in the connection settings first.",
|
||||
"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": {
|
||||
@@ -146,7 +150,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableLocalConnectionMethodFirst": "Enable Local Connection method, first.",
|
||||
"enableMediaKeyDetection": "Enable Media Key Detection",
|
||||
"enableMywhooshLinkInTheConnectionSettingsFirst": "Enable MyWhoosh Link in the connection settings first.",
|
||||
"enablePairingProcess": "Enable Pairing Process",
|
||||
"enablePermissions": "Enable Permissions",
|
||||
"enableSteeringWithPhone": "Enable Phones' sensors to enable e.g. steering",
|
||||
@@ -214,6 +220,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsGetYouSetUp": "Let's get you set up!",
|
||||
"license": "License",
|
||||
"licenseStatus": "License Status",
|
||||
"loadScreenshotForPlacement": "Load in-game screenshot for placement",
|
||||
@@ -239,7 +246,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.",
|
||||
@@ -379,17 +386,19 @@
|
||||
"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",
|
||||
"supportedActions": "Supported Actions",
|
||||
"targetOtherDevice": "Other Device",
|
||||
"targetThisDevice": "This Device",
|
||||
"theFollowingPermissionsRequired": "The following permissions are required:",
|
||||
@@ -400,10 +409,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": "Unlocking is currently not yet possible, so enjoy unlimited usage for the time being!",
|
||||
"update": "Update",
|
||||
"useCustomKeymapForButton": "Use a custom keymap to support the",
|
||||
"version": "Version {version}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl a besoin d'une autorisation d'accessibilité pour contrôler vos applications d'entraînement.",
|
||||
"accessibilityDisclaimer": "BikeControl n'accédera à votre écran que pour effectuer les gestes que vous aurez configurés. Aucune autre fonctionnalité d'accessibilité ni aucune autre information personnelle ne sera accessible.",
|
||||
"accessibilityReasonControl": "• Pour vous permettre de contrôler des applications telles que MyWhoosh, IndieVelo et d'autres à l'aide de vos appareils Zwift.",
|
||||
"accessibilityReasonControl": "• Pour vous permettre de contrôler des applications telles que MyWhoosh, TrainingPeaks et d'autres à l'aide de vos appareils Zwift.",
|
||||
"accessibilityReasonTouch": "• Pour simuler des gestes tactiles sur votre écran afin de contrôler les applications d'entraînement",
|
||||
"accessibilityReasonWindow": "• Pour détecter quelle fenêtre de l'application d'entraînement est actuellement active",
|
||||
"accessibilityServiceExplanation": "BikeControl doit utiliser l'API AccessibilityService d'Android pour fonctionner correctement.",
|
||||
@@ -23,6 +23,7 @@
|
||||
"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 ?",
|
||||
"androidSystemAction": "Actions système Android",
|
||||
"appIdActions": "{appId} actions",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
@@ -31,6 +32,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"asAFinalStepYoullChooseHowToConnectTo": "Dernière étape : vous choisirez comment vous connecter à {trainerApp} .",
|
||||
"battery": "Batterie",
|
||||
"beforeDate": "Avant {date}",
|
||||
"bluetoothAdvertiseAccess": "Accès à la publicité Bluetooth",
|
||||
@@ -44,7 +46,7 @@
|
||||
"chooseBikeControlInConnectionScreen": "Sélectionnez BikeControl dans l'écran de connexion.",
|
||||
"clickAButtonOnYourController": "Cliquez sur un bouton de votre manette pour modifier son action ou appuyez sur l'icône de modification.",
|
||||
"clickV2EventInfo": "Votre Click V2 n'envoie peut-être plus les événements liés aux boutons. Vérifiez en appuyant sur quelques boutons et voyez s'ils apparaissent dans BikeControl.",
|
||||
"clickV2Instructions": "Pour que ton Zwift Click V2 marche super bien, tu dois le connecter à l'appli Zwift une fois par jour.\nSi tu ne le fais pas, le Click V2 s'arrêtera de fonctionner au bout d'une minute.\n\n1. Ouvre l'appli Zwift.\n2. Connecte-toi (pas besoin d'abonnement) et ouvre l'écran de connexion des appareils.\n3. Connecte ton home trainer, puis connecte le Zwift Click V2.\n4. Ferme l'appli Zwift et reconnecte-toi dans BikeControl.",
|
||||
"clickV2Instructions": "Pour que ton Zwift Click V2 marche super bien, tu dois le connecter à l'appli Zwift une fois par jour.\nSi tu ne le fais pas, le Click V2 s'arrêtera de fonctionner au bout d'une minute.\n\n1. Ouvre l'appli Zwift (pas Companion!)\n2. Connecte-toi (pas besoin d'abonnement) et ouvre l'écran de connexion des appareils.\n3. Connecte ton home trainer, puis connecte le Zwift Click V2.\n4. Ferme l'appli Zwift et reconnecte-toi dans BikeControl.",
|
||||
"close": "Fermer",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} commandes restantes aujourd'hui",
|
||||
"configuration": "Configuration",
|
||||
@@ -77,6 +79,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",
|
||||
@@ -137,8 +140,9 @@
|
||||
"duplicate": "Double",
|
||||
"enableAutoRotation": "Activez la rotation automatique sur votre appareil pour vous assurer que l'application fonctionne correctement.",
|
||||
"enableBluetooth": "Activer le Bluetooth",
|
||||
"enableItInTheConnectionSettingsFirst": "Activez-le d'abord dans les paramètres de connexion.",
|
||||
"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": {
|
||||
@@ -146,7 +150,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableLocalConnectionMethodFirst": "Activez d'abord le mode de connexion locale.",
|
||||
"enableMediaKeyDetection": "Activer la détection des touches multimédias",
|
||||
"enableMywhooshLinkInTheConnectionSettingsFirst": "Activez d'abord MyWhoosh Link dans les paramètres de connexion.",
|
||||
"enablePairingProcess": "Activer le processus d'appairage",
|
||||
"enablePermissions": "Activer les autorisations",
|
||||
"enableSteeringWithPhone": "Activez les capteurs du téléphone pour permettre, par exemple, la direction.",
|
||||
@@ -214,6 +220,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsGetYouSetUp": "On va vous installer !",
|
||||
"license": "Licence",
|
||||
"licenseStatus": "Statut de la licence",
|
||||
"loadScreenshotForPlacement": "Charger une capture d'écran du jeu pour le placement",
|
||||
@@ -239,7 +246,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.",
|
||||
@@ -379,17 +386,19 @@
|
||||
"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",
|
||||
"supportedActions": "Actions prises en charge",
|
||||
"targetOtherDevice": "Autre appareil",
|
||||
"targetThisDevice": "Cet appareil",
|
||||
"theFollowingPermissionsRequired": "Les autorisations suivantes sont requises :",
|
||||
@@ -400,10 +409,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": "Le déverrouillage n'est pas encore possible pour le moment, alors profitez d'une utilisation illimitée pour l'instant !",
|
||||
"update": "Mise à jour",
|
||||
"useCustomKeymapForButton": "Utilisez une configuration de touches personnalisée pour prendre en charge",
|
||||
"version": "Version {version}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"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",
|
||||
"accessibilityReasonControl": "• Per consentirti di controllare app come MyWhoosh, TrainingPeaks 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.",
|
||||
@@ -14,7 +14,7 @@
|
||||
"action": "Azione",
|
||||
"adjustControllerButtons": "Regola i pulsanti del controller",
|
||||
"afterDate": "Dopo il {date}",
|
||||
"allow": "Consentire",
|
||||
"allow": "Consenti",
|
||||
"allowAccessibilityService": "Consenti servizio di accessibilità",
|
||||
"allowBluetoothConnections": "Consenti connessioni Bluetooth",
|
||||
"allowBluetoothScan": "Consenti scansione Bluetooth",
|
||||
@@ -23,6 +23,7 @@
|
||||
"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?",
|
||||
"androidSystemAction": "Azione del sistema Android",
|
||||
"appIdActions": "{appId} azioni",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
@@ -31,6 +32,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"asAFinalStepYoullChooseHowToConnectTo": "Come ultimo passaggio sceglierai come connetterti a {trainerApp} .",
|
||||
"battery": "Batteria",
|
||||
"beforeDate": "Prima del {date}",
|
||||
"bluetoothAdvertiseAccess": "Accesso pubblicitario Bluetooth",
|
||||
@@ -44,7 +46,7 @@
|
||||
"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",
|
||||
"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. \n\n1. Apri l'app Zwift (non il Companion!)\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",
|
||||
@@ -77,6 +79,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -137,8 +140,9 @@
|
||||
"duplicate": "Duplica",
|
||||
"enableAutoRotation": "Abilita la rotazione automatica sul tuo dispositivo per assicurarti che l'app funzioni correttamente.",
|
||||
"enableBluetooth": "Abilita Bluetooth",
|
||||
"enableItInTheConnectionSettingsFirst": "Per prima cosa abilitalo nelle impostazioni di connessione.",
|
||||
"enableKeyboardAccessMessage": "Abilita l'accesso tramite tastiera nella schermata seguente per BikeControl. Se BikeControl non è presente, aggiungilo manualmente.",
|
||||
"enableKeyboardMouseControl": "Abilita il controllo della tastiera e del mouse per una migliore interazione con{appName} Una volta attivo, non sono necessarie ulteriori azioni o connessioni. BikeControl invierà direttamente l'input del mouse o della tastiera a{appName} .",
|
||||
"enableKeyboardMouseControl": "BikeControl invierà azioni del mouse o della tastiera per controllare {appName} .",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
@@ -146,7 +150,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableLocalConnectionMethodFirst": "Abilitare prima il metodo di connessione locale.",
|
||||
"enableMediaKeyDetection": "Abilita rilevamento tasti multimediali",
|
||||
"enableMywhooshLinkInTheConnectionSettingsFirst": "Per prima cosa, abilita MyWhoosh Link nelle impostazioni di connessione.",
|
||||
"enablePairingProcess": "Abilita processo di associazione",
|
||||
"enablePermissions": "Abilita autorizzazioni",
|
||||
"enableSteeringWithPhone": "Abilita i sensori dei telefoni per abilitare ad esempio lo sterzo",
|
||||
@@ -214,6 +220,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsGetYouSetUp": "Ti aiuteremo a prepararti!",
|
||||
"license": "Licenza",
|
||||
"licenseStatus": "Stato della licenza",
|
||||
"loadScreenshotForPlacement": "Carica lo screenshot del gioco per il posizionamento",
|
||||
@@ -239,7 +246,7 @@
|
||||
"myWhooshDirectConnectAction": "Azione MyWhoosh \"Link\"",
|
||||
"myWhooshDirectConnection": " ad esempio utilizzando MyWhoosh \"Link\"",
|
||||
"myWhooshLinkConnected": "MyWhoosh \"Link\" connesso",
|
||||
"myWhooshLinkDescriptionLocal": "Collegati direttamente a MyWhoosh tramite il metodo \"Link\". Le azioni supportate includono cambio, emoji, indicazioni di svolta, tra le altre. L'app complementare MyWhoosh Link NON deve essere in esecuzione contemporaneamente.",
|
||||
"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.",
|
||||
@@ -379,17 +386,19 @@
|
||||
"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",
|
||||
"showTroubleshootingGuide": "Mostra Guida alla risoluzione dei problemi",
|
||||
"signal": "Segnale",
|
||||
"simulateButtons": "Controlli del Trainer",
|
||||
"simulateKeyboardShortcut": "Simula scorciatoia da tastiera",
|
||||
"simulateMediaKey": "Simula il tasto multimediale",
|
||||
"simulateTouch": "Simula il tocco",
|
||||
"skip": "Saltare",
|
||||
"stop": "Stop",
|
||||
"supportedActions": "Azioni supportate",
|
||||
"targetOtherDevice": "Altro dispositivo",
|
||||
"targetThisDevice": "Questo dispositivo",
|
||||
"theFollowingPermissionsRequired": "Sono richieste le seguenti autorizzazioni:",
|
||||
@@ -400,10 +409,11 @@
|
||||
"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": "Guida alla risoluzione dei problemi",
|
||||
"troubleshootingGuide": "Domande e risposte",
|
||||
"tryingToConnectAgain": "Sto provando a connettermi di nuovo...",
|
||||
"unassignAction": "Annulla assegnazione azione",
|
||||
"unlockFullVersion": "Sblocca la versione completa",
|
||||
"unlockingNotPossible": "Al momento non è ancora possibile sbloccarlo, quindi per il momento goditi un utilizzo illimitato!",
|
||||
"update": "Aggiorna",
|
||||
"useCustomKeymapForButton": "Utilizzare una mappa dei tasti personalizzata per supportare",
|
||||
"version": "Versione{version}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl potrzebuje uprawnień dostępu, aby móc sterować aplikacjami treningowymi.",
|
||||
"accessibilityDisclaimer": "BikeControl będzie miał dostęp do Twojego ekranu wyłącznie w celu wykonania skonfigurowanych przez Ciebie gestów. Nie będzie miał dostępu do żadnych innych funkcji ułatwień dostępu ani danych osobowych.",
|
||||
"accessibilityReasonControl": "• Aby umożliwić Ci sterowanie aplikacjami takimi jak MyWhoosh, IndieVelo i innymi za pomocą urządzeń Zwift",
|
||||
"accessibilityReasonControl": "• Aby umożliwić Ci sterowanie aplikacjami takimi jak MyWhoosh, TrainingPeaks i innymi za pomocą urządzeń Zwift",
|
||||
"accessibilityReasonTouch": "• Aby symulować gesty dotykowe na ekranie w celu sterowania aplikacją treningową",
|
||||
"accessibilityReasonWindow": "• Aby wykryć, które okno aplikacji treningowej jest aktualnie aktywne",
|
||||
"accessibilityServiceExplanation": "Aby działać prawidłowo, BikeControl musi korzystać z interfejsu API AccessibilityService systemu Android.",
|
||||
@@ -23,6 +23,7 @@
|
||||
"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ę?",
|
||||
"androidSystemAction": "Akcja systemu Android",
|
||||
"appIdActions": "{appId} działania",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
@@ -31,6 +32,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"asAFinalStepYoullChooseHowToConnectTo": "W ostatnim kroku wybierzesz sposób połączenia {trainerApp} .",
|
||||
"battery": "Bateria",
|
||||
"beforeDate": "Zanim {date}",
|
||||
"bluetoothAdvertiseAccess": "Dostęp do reklamy Bluetooth",
|
||||
@@ -38,13 +40,13 @@
|
||||
"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.",
|
||||
"clickAButtonOnYourController": "Kliknij przycisk na kontrolerze, aby edytować jego akcję lub naciśnij ikonę edycji.",
|
||||
"clickV2EventInfo": "Twóje Click V2 może już nie wysyłać zdarzeń przycisków. Sprawdź, naciskając kilka przycisków, czy są one widoczne w BikeControl.",
|
||||
"clickV2Instructions": "Aby Twoje Zwift Click V2 działały optymalnie, należy połączyć je z aplikacją Zwift przed każdym treningiem.\nJeśli tego nie zrobisz, Click V2 przestanie działać po minucie.\n\n1. Otwórz aplikację Zwift\n2. Zaloguj się (nie wymaga subskrypcji) i otwórz ekran połączeń z urządzeniami\n3. Połącz się z trenażerem, a następnie z Zwift Click V2\n4. Zamknij aplikację Zwift i ponownie połącz się z BikeControl",
|
||||
"clickV2Instructions": "Aby Twoje Zwift Click V2 działały optymalnie, należy połączyć je z aplikacją Zwift przed każdym treningiem.\nJeśli tego nie zrobisz, Click V2 przestanie działać po minucie.\n\n1. Otwórz aplikację Zwift (nie Companion!)\n2. Zaloguj się (nie wymaga subskrypcji) i otwórz ekran połączeń z urządzeniami\n3. Połącz się z trenażerem, a następnie z Zwift Click V2\n4. Zamknij aplikację Zwift i ponownie połącz się z BikeControl",
|
||||
"close": "Zamknij",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} dzisiejsze pozostałe polecenia",
|
||||
"configuration": "Konfiguracja",
|
||||
@@ -77,6 +79,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",
|
||||
@@ -137,8 +140,9 @@
|
||||
"duplicate": "Duplikuj",
|
||||
"enableAutoRotation": "Włącz funkcję automatycznego obracania na urządzeniu, aby mieć pewność, że aplikacja będzie działać prawidłowo.",
|
||||
"enableBluetooth": "Włącz Bluetooth",
|
||||
"enableItInTheConnectionSettingsFirst": "Najpierw włącz tę opcję w ustawieniach połączenia.",
|
||||
"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": {
|
||||
@@ -146,7 +150,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableLocalConnectionMethodFirst": "Najpierw włącz metodę połączenia lokalnego.",
|
||||
"enableMediaKeyDetection": "Włącz rozpoznawanie klawiszy multimedialnych",
|
||||
"enableMywhooshLinkInTheConnectionSettingsFirst": "Najpierw włącz MyWhoosh Link w ustawieniach połączenia.",
|
||||
"enablePairingProcess": "Włącz proces parowania",
|
||||
"enablePermissions": "Nadaj uprawnienia",
|
||||
"enableSteeringWithPhone": "Włącz czujniki telefonów, aby umożliwić np. sterowanie",
|
||||
@@ -214,10 +220,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",
|
||||
@@ -239,10 +246,10 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -379,17 +386,19 @@
|
||||
"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",
|
||||
"supportedActions": "Obsługiwane działania",
|
||||
"targetOtherDevice": "Inne urządzenie",
|
||||
"targetThisDevice": "To urządzenie",
|
||||
"theFollowingPermissionsRequired": "Wymagane są następujące uprawnienia:",
|
||||
@@ -400,10 +409,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": "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}",
|
||||
|
||||
@@ -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';
|
||||
@@ -103,12 +104,12 @@ Future<void> _persistCrash({
|
||||
..writeln('Error: $error')
|
||||
..writeln('Stack: ${stack ?? 'no stack'}')
|
||||
..writeln('Info: ${information ?? ''}')
|
||||
..writeln(debugText())
|
||||
..writeln(await debugText())
|
||||
..writeln()
|
||||
..writeln();
|
||||
|
||||
final directory = await _getLogDirectory();
|
||||
final file = File('${directory.path}/app.logs');
|
||||
final file = File('${directory.path}/app.log');
|
||||
final fileLength = await file.length();
|
||||
if (fileLength > 5 * 1024 * 1024) {
|
||||
// If log file exceeds 5MB, truncate it
|
||||
@@ -163,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;
|
||||
@@ -197,27 +205,67 @@ 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(() {
|
||||
if (core.obpMdnsEmulator.connectedApp.value == null) {
|
||||
_showPage = BCPage.trainer;
|
||||
} else {
|
||||
_showPage = BCPage.devices;
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
: 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();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
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/pages/touch_area.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
@@ -14,6 +15,7 @@ import 'package:bike_control/widgets/custom_keymap_selector.dart';
|
||||
import 'package:bike_control/widgets/ui/button_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -109,7 +111,6 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('Editing').h3,
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOut,
|
||||
@@ -154,15 +155,31 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
..._buildTrainerConnectionActions(core.logic.obpConnectedApp!.supportedActions),
|
||||
],
|
||||
|
||||
if (core.settings.getMyWhooshLinkEnabled() && core.logic.showMyWhooshLink) ...[
|
||||
if (core.logic.showMyWhooshLink) ...[
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: context.i18n.myWhooshDirectConnectAction),
|
||||
..._buildTrainerConnectionActions(core.whooshLink.supportedActions),
|
||||
if (!core.settings.getMyWhooshLinkEnabled())
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context).enableMywhooshLinkInTheConnectionSettingsFirst),
|
||||
],
|
||||
)
|
||||
else
|
||||
..._buildTrainerConnectionActions(core.whooshLink.supportedActions),
|
||||
],
|
||||
if (core.logic.isZwiftBleEnabled || core.logic.isZwiftMdnsEnabled) ...[
|
||||
if (core.logic.showZwiftBleEmulator || core.logic.showZwiftMsdnEmulator) ...[
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: context.i18n.zwiftControllerAction),
|
||||
..._buildTrainerConnectionActions(core.zwiftEmulator.supportedActions),
|
||||
if (!core.settings.getZwiftBleEmulatorEnabled() && !core.settings.getZwiftMdnsEmulatorEnabled())
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context).enableItInTheConnectionSettingsFirst),
|
||||
],
|
||||
)
|
||||
else
|
||||
..._buildTrainerConnectionActions(core.zwiftEmulator.supportedActions),
|
||||
],
|
||||
|
||||
if (core.logic.showLocalRemoteOptions) ...[
|
||||
@@ -175,40 +192,48 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
title: Text(context.i18n.predefinedAction(trainerApp.name)),
|
||||
isActive: false,
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: actionsWithInGameAction!.map((keyPairAction) {
|
||||
return MenuButton(
|
||||
leading: keyPairAction.inGameAction?.icon != null
|
||||
? Icon(keyPairAction.inGameAction!.icon)
|
||||
: null,
|
||||
onPressed: (_) {
|
||||
// Copy all properties from the selected predefined action
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) {
|
||||
_keyPair.physicalKey = keyPairAction.physicalKey;
|
||||
_keyPair.logicalKey = keyPairAction.logicalKey;
|
||||
_keyPair.modifiers = List.of(keyPairAction.modifiers);
|
||||
} else {
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.modifiers = [];
|
||||
}
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.touch)) {
|
||||
_keyPair.touchPosition = keyPairAction.touchPosition;
|
||||
} else {
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
}
|
||||
_keyPair.isLongPress = keyPairAction.isLongPress;
|
||||
_keyPair.inGameAction = keyPairAction.inGameAction;
|
||||
_keyPair.inGameActionValue = keyPairAction.inGameActionValue;
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(keyPairAction.toString()),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
if (!core.settings.getLocalEnabled()) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: AppLocalizations.of(context).enableLocalConnectionMethodFirst,
|
||||
);
|
||||
} else {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: actionsWithInGameAction!.map((keyPairAction) {
|
||||
return MenuButton(
|
||||
leading: keyPairAction.inGameAction?.icon != null
|
||||
? Icon(keyPairAction.inGameAction!.icon)
|
||||
: null,
|
||||
onPressed: (_) {
|
||||
// Copy all properties from the selected predefined action
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) {
|
||||
_keyPair.physicalKey = keyPairAction.physicalKey;
|
||||
_keyPair.logicalKey = keyPairAction.logicalKey;
|
||||
_keyPair.modifiers = List.of(keyPairAction.modifiers);
|
||||
} else {
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.modifiers = [];
|
||||
}
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.touch)) {
|
||||
_keyPair.touchPosition = keyPairAction.touchPosition;
|
||||
} else {
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
}
|
||||
_keyPair.isLongPress = keyPairAction.isLongPress;
|
||||
_keyPair.inGameAction = keyPairAction.inGameAction;
|
||||
_keyPair.inGameActionValue = keyPairAction.inGameActionValue;
|
||||
_keyPair.androidAction = null;
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(keyPairAction.toString()),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -217,42 +242,62 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
SelectableCard(
|
||||
icon: RadixIcons.keyboard,
|
||||
title: Text(context.i18n.simulateKeyboardShortcut),
|
||||
isActive: _keyPair.physicalKey != null && !_keyPair.isSpecialKey,
|
||||
isActive:
|
||||
_keyPair.physicalKey != null && !_keyPair.isSpecialKey && core.settings.getLocalEnabled(),
|
||||
value: _keyPair.toString(),
|
||||
onPressed: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // enable Escape key
|
||||
builder: (c) => HotKeyListenerDialog(
|
||||
customApp: core.actionHandler.supportedApp! as CustomApp,
|
||||
keyPair: _keyPair,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
if (!core.settings.getLocalEnabled()) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: AppLocalizations.of(context).enableLocalConnectionMethodFirst,
|
||||
);
|
||||
} else {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // enable Escape key
|
||||
builder: (c) => HotKeyListenerDialog(
|
||||
customApp: core.actionHandler.supportedApp! as CustomApp,
|
||||
keyPair: _keyPair,
|
||||
),
|
||||
);
|
||||
_keyPair.androidAction = null;
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.touch))
|
||||
SelectableCard(
|
||||
title: Text(context.i18n.simulateTouch),
|
||||
icon: core.actionHandler is AndroidActions ? Icons.touch_app_outlined : BootstrapIcons.mouse,
|
||||
isActive: _keyPair.physicalKey == null && _keyPair.touchPosition != Offset.zero,
|
||||
isActive:
|
||||
((core.actionHandler is AndroidActions || _keyPair.physicalKey == null) &&
|
||||
_keyPair.touchPosition != Offset.zero) &&
|
||||
core.settings.getLocalEnabled(),
|
||||
value: _keyPair.toString(),
|
||||
onPressed: () async {
|
||||
if (_keyPair.touchPosition == Offset.zero) {
|
||||
_keyPair.touchPosition = Offset(50, 50);
|
||||
}
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
await Navigator.of(context).push<bool?>(
|
||||
MaterialPageRoute(
|
||||
builder: (c) => TouchAreaSetupPage(
|
||||
keyPair: _keyPair,
|
||||
if (!core.settings.getLocalEnabled()) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: AppLocalizations.of(context).enableLocalConnectionMethodFirst,
|
||||
);
|
||||
} else {
|
||||
if (_keyPair.touchPosition == Offset.zero) {
|
||||
_keyPair.touchPosition = Offset(50, 50);
|
||||
}
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.androidAction = null;
|
||||
await Navigator.of(context).push<bool?>(
|
||||
MaterialPageRoute(
|
||||
builder: (c) => TouchAreaSetupPage(
|
||||
keyPair: _keyPair,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
);
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -260,83 +305,141 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
Builder(
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.music_note_outlined,
|
||||
isActive: _keyPair.isSpecialKey,
|
||||
isActive: _keyPair.isSpecialKey && core.settings.getLocalEnabled(),
|
||||
title: Text(context.i18n.simulateMediaKey),
|
||||
value: _keyPair.toString(),
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
leading: Icon(Icons.play_arrow_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaPlayPause;
|
||||
_keyPair.logicalKey = null;
|
||||
if (!core.settings.getLocalEnabled()) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: AppLocalizations.of(context).enableLocalConnectionMethodFirst,
|
||||
);
|
||||
} else {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
leading: Icon(Icons.play_arrow_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaPlayPause;
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.androidAction = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.playPause),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.stop_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaStop;
|
||||
_keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.playPause),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.stop_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaStop;
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.androidAction = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.stop),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.skip_previous_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackPrevious;
|
||||
_keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.stop),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.skip_previous_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackPrevious;
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.androidAction = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.previous),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.skip_next_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackNext;
|
||||
_keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.previous),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.skip_next_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackNext;
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.androidAction = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.next),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.volume_up_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeUp;
|
||||
_keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.next),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.volume_up_outlined),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeUp;
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.androidAction = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.volumeUp),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.volume_down_outlined),
|
||||
child: Text(context.i18n.volumeDown),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeDown;
|
||||
_keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.volumeUp),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.volume_down_outlined),
|
||||
child: Text(context.i18n.volumeDown),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeDown;
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.androidAction = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
if (core.logic.showLocalControl && core.actionHandler is AndroidActions)
|
||||
Builder(
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.settings_remote_outlined,
|
||||
isActive: _keyPair.androidAction != null && core.settings.getLocalEnabled(),
|
||||
title: Text(AppLocalizations.of(context).androidSystemAction),
|
||||
value: _keyPair.androidAction?.title,
|
||||
onPressed: () {
|
||||
if (!core.settings.getLocalEnabled()) {
|
||||
buildToast(navigatorKey.currentContext!, title: 'Enable Local Connection method, first.');
|
||||
} else {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: AndroidSystemAction.values
|
||||
.map(
|
||||
(action) => MenuButton(
|
||||
leading: Icon(action.icon),
|
||||
onPressed: (_) {
|
||||
_keyPair.androidAction = action;
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.modifiers = [];
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.inGameAction = null;
|
||||
_keyPair.inGameActionValue = null;
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(action.title),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -369,6 +472,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
onPressed: (_) {
|
||||
_keyPair.inGameAction = InGameAction.headwindSpeed;
|
||||
_keyPair.inGameActionValue = value;
|
||||
_keyPair.androidAction = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
@@ -382,6 +486,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
onPressed: (_) {
|
||||
_keyPair.inGameAction = InGameAction.headwindHeartRateMode;
|
||||
_keyPair.inGameActionValue = null;
|
||||
_keyPair.androidAction = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
@@ -416,6 +521,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.inGameAction = null;
|
||||
_keyPair.inGameActionValue = null;
|
||||
_keyPair.androidAction = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
@@ -456,6 +562,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.androidAction = null;
|
||||
_keyPair.inGameAction = action;
|
||||
_keyPair.inGameActionValue = ingame;
|
||||
widget.onUpdate();
|
||||
@@ -470,6 +577,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.androidAction = null;
|
||||
_keyPair.inGameAction = action;
|
||||
_keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
@@ -524,7 +632,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),
|
||||
|
||||
@@ -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: [
|
||||
@@ -51,7 +53,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(screenshotMode ? 'Trainer app' : app.name),
|
||||
if (app.supportsOpenBikeProtocol) Icon(Icons.star),
|
||||
if (app.supportsOpenBikeProtocol.isNotEmpty) Icon(Icons.star),
|
||||
],
|
||||
),
|
||||
popup: SelectPopup(
|
||||
@@ -63,7 +65,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(app.name),
|
||||
if (app.supportsOpenBikeProtocol) Icon(Icons.star),
|
||||
if (app.supportsOpenBikeProtocol.isNotEmpty) Icon(Icons.star),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -86,7 +88,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
core.zwiftEmulator.stopAdvertising();
|
||||
}
|
||||
}
|
||||
if (!selectedApp.supportsOpenBikeProtocol) {
|
||||
if (selectedApp.supportsOpenBikeProtocol.isEmpty) {
|
||||
if (core.obpMdnsEmulator.isStarted.value) {
|
||||
core.obpMdnsEmulator.stopServer();
|
||||
}
|
||||
@@ -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.isNotEmpty &&
|
||||
!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: [
|
||||
@@ -190,13 +194,13 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
Future<void> _setTarget(BuildContext context, Target target) async {
|
||||
await core.settings.setLastTarget(target);
|
||||
|
||||
if (core.settings.getTrainerApp()?.supportsOpenBikeProtocol == true && !core.logic.emulatorEnabled) {
|
||||
if ((core.settings.getTrainerApp()?.supportsOpenBikeProtocol.isNotEmpty ?? false) && !core.logic.emulatorEnabled) {
|
||||
core.settings.setObpMdnsEnabled(true);
|
||||
}
|
||||
|
||||
// enable local connection on Windows if the app doesn't support OBP
|
||||
if (target == Target.thisDevice &&
|
||||
core.settings.getTrainerApp()?.supportsOpenBikeProtocol == false &&
|
||||
core.settings.getTrainerApp()?.supportsOpenBikeProtocol.isEmpty == true &&
|
||||
!kIsWeb &&
|
||||
Platform.isWindows) {
|
||||
core.settings.setLocalEnabled(true);
|
||||
|
||||
@@ -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.isNotEmpty)
|
||||
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,19 +2,23 @@ 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();
|
||||
@@ -43,10 +47,9 @@ class _DevicePageState extends State<DevicePage> {
|
||||
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,29 +65,18 @@ class _DevicePageState extends State<DevicePage> {
|
||||
// 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,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.95),
|
||||
child: device.showInformation(context),
|
||||
),
|
||||
),
|
||||
|
||||
if (core.connection.accessories.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(text: AppLocalizations.of(context).accessories),
|
||||
),
|
||||
...core.connection.accessories.map(
|
||||
(device) => Card(
|
||||
(device) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
@@ -92,8 +84,97 @@ class _DevicePageState extends State<DevicePage> {
|
||||
child: device.showInformation(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Gap(12),
|
||||
if (core.connection.accessories.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(text: AppLocalizations.of(context).accessories),
|
||||
),
|
||||
...core.connection.accessories.map(
|
||||
(device) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.95),
|
||||
child: device.showInformation(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
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';
|
||||
@@ -64,7 +65,6 @@ class _NavigationState extends State<Navigation> {
|
||||
|
||||
_selectedPage = widget.page;
|
||||
|
||||
core.connection.initialize();
|
||||
core.logic.startEnabledConnectionMethod();
|
||||
|
||||
core.connection.actionStream.listen((_) {
|
||||
@@ -134,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: [
|
||||
@@ -163,21 +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(
|
||||
key: _pageKeys[BCPage.devices],
|
||||
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(
|
||||
key: _pageKeys[BCPage.trainer],
|
||||
),
|
||||
BCPage.trainer => Align(
|
||||
alignment: Alignment.topLeft,
|
||||
key: _pageKeys[BCPage.trainer],
|
||||
child: TrainerPage(
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
},
|
||||
@@ -186,15 +206,21 @@ class _NavigationState extends State<Navigation> {
|
||||
_selectedPage = BCPage.customization;
|
||||
});
|
||||
},
|
||||
isMobile: _isMobile,
|
||||
),
|
||||
BCPage.customization => CustomizePage(
|
||||
key: _pageKeys[BCPage.customization],
|
||||
),
|
||||
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],
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
397
lib/pages/onboarding.dart
Normal file
397
lib/pages/onboarding.dart
Normal file
@@ -0,0 +1,397 @@
|
||||
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/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/openbikecontrol_mdns_tile.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,
|
||||
openbikecontrol,
|
||||
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(() {
|
||||
if (core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(
|
||||
OpenBikeProtocolSupport.network,
|
||||
) ??
|
||||
false) {
|
||||
_currentStep = _OnboardingStep.openbikecontrol;
|
||||
} else {
|
||||
_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),
|
||||
if (core.obpMdnsEmulator.connectedApp.value == null)
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
_OnboardingStep.openbikecontrol => _OpenBikeControlConnectPage(
|
||||
onComplete: () {
|
||||
setState(() {
|
||||
_currentStep = _OnboardingStep.finish;
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenBikeControlConnectPage extends StatefulWidget {
|
||||
final VoidCallback onComplete;
|
||||
const _OpenBikeControlConnectPage({super.key, required this.onComplete});
|
||||
|
||||
@override
|
||||
State<_OpenBikeControlConnectPage> createState() => _OpenBikeControlConnectPageState();
|
||||
}
|
||||
|
||||
class _OpenBikeControlConnectPageState extends State<_OpenBikeControlConnectPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!core.obpMdnsEmulator.isStarted.value) {
|
||||
if (!core.logic.isObpMdnsEnabled) {
|
||||
core.settings.setObpMdnsEnabled(true);
|
||||
}
|
||||
core.logic.startEnabledConnectionMethod();
|
||||
}
|
||||
core.obpMdnsEmulator.connectedApp.addListener(() {
|
||||
if (core.obpMdnsEmulator.connectedApp.value != null) {
|
||||
widget.onComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 22,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).openBikeControlAnnouncement(core.settings.getTrainerApp()!.name),
|
||||
).small,
|
||||
OpenBikeControlMdnsTile(),
|
||||
|
||||
if (core.settings.getLastTarget() == Target.otherDevice &&
|
||||
core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.ble) == true) ...[
|
||||
SizedBox(height: 20),
|
||||
Text('If you have issues with your network connection, you can also connect via Bluetooth.').small.muted,
|
||||
OpenBikeControlBluetoothTile(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
final flutterView = WidgetsBinding.instance.platformDispatcher.views.first;
|
||||
|
||||
// figure out notch height for e.g. macOS. On Windows the display size is not available (0,0).
|
||||
final differenceInHeight = (flutterView.display.size.height > 0 && !Platform.isIOS)
|
||||
final differenceInHeight = (!Platform.isWindows && flutterView.display.size.height > 0 && !Platform.isIOS)
|
||||
? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio
|
||||
: 0.0;
|
||||
|
||||
|
||||
@@ -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.isNotEmpty) 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.isNotEmpty) 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 || MediaQuery.sizeOf(context).width < 750 ? Axis.vertical : Axis.horizontal,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
@@ -14,6 +16,7 @@ class AndroidActions extends BaseActions {
|
||||
WindowEvent? windowInfo;
|
||||
|
||||
final accessibilityHandler = Accessibility();
|
||||
StreamSubscription<void>? _keymapUpdateSubscription;
|
||||
|
||||
AndroidActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.media]});
|
||||
|
||||
@@ -26,11 +29,22 @@ class AndroidActions extends BaseActions {
|
||||
}
|
||||
});
|
||||
|
||||
// Update handled keys list when keymap changes
|
||||
updateHandledKeys();
|
||||
|
||||
// Listen to keymap changes and update handled keys
|
||||
_keymapUpdateSubscription?.cancel();
|
||||
_keymapUpdateSubscription = supportedApp?.keymap.updateStream.listen((_) {
|
||||
updateHandledKeys();
|
||||
});
|
||||
|
||||
hidKeyPressed().listen((keyPressed) async {
|
||||
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;
|
||||
@@ -65,6 +79,15 @@ class AndroidActions extends BaseActions {
|
||||
return Success("Key pressed: ${keyPair.toString()}");
|
||||
}
|
||||
|
||||
if (keyPair.androidAction != null) {
|
||||
if (!core.settings.getLocalEnabled() || !core.logic.showLocalControl || !isKeyDown) {
|
||||
return Ignored('Global action ignored');
|
||||
}
|
||||
await accessibilityHandler.performGlobalAction(keyPair.androidAction!.globalAction);
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
return Success("Global action: ${keyPair.androidAction!.title}");
|
||||
}
|
||||
|
||||
final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: windowInfo);
|
||||
if (point != Offset.zero) {
|
||||
try {
|
||||
@@ -88,4 +111,22 @@ class AndroidActions extends BaseActions {
|
||||
void ignoreHidDevices() {
|
||||
accessibilityHandler.ignoreHidDevices();
|
||||
}
|
||||
|
||||
void updateHandledKeys() {
|
||||
if (supportedApp == null) {
|
||||
accessibilityHandler.setHandledKeys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all keys from the keymap that have a mapping defined
|
||||
final handledKeys = supportedApp!.keymap.keyPairs
|
||||
.filter((keyPair) => !keyPair.hasNoAction)
|
||||
.expand((keyPair) => keyPair.buttons)
|
||||
.filter((e) => e.action == null && e.icon == null)
|
||||
.map((button) => button.name)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
accessibilityHandler.setHandledKeys(handledKeys);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]});
|
||||
@@ -31,13 +32,30 @@ class DesktopActions extends BaseActions {
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
@@ -15,22 +14,21 @@ import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/actions/remote.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/android.dart';
|
||||
import 'package:bike_control/utils/settings/settings.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:media_key_detector/media_key_detector.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth/connection.dart';
|
||||
import '../bluetooth/devices/mywhoosh/link.dart';
|
||||
import 'keymap/apps/rouvy.dart';
|
||||
import 'media_key_handler.dart';
|
||||
import 'requirements/multi.dart';
|
||||
import 'requirements/platform.dart';
|
||||
import 'smtc_stub.dart' if (dart.library.io) 'package:smtc_windows/smtc_windows.dart';
|
||||
|
||||
final core = Core();
|
||||
|
||||
@@ -67,7 +65,7 @@ class Permissions {
|
||||
} else if (Platform.isMacOS) {
|
||||
list = [
|
||||
BluetoothTurnedOn(),
|
||||
NotificationRequirement(),
|
||||
if (core.settings.getShowOnboarding()) NotificationRequirement(),
|
||||
];
|
||||
} else if (Platform.isIOS) {
|
||||
list = [
|
||||
@@ -170,11 +168,11 @@ class CoreLogic {
|
||||
}
|
||||
|
||||
bool get showObpMdnsEmulator {
|
||||
return core.settings.getTrainerApp()?.supportsOpenBikeProtocol == true;
|
||||
return core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.network) == true;
|
||||
}
|
||||
|
||||
bool get showObpBluetoothEmulator {
|
||||
return (core.settings.getTrainerApp()?.supportsOpenBikeProtocol == true) &&
|
||||
return (core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.ble) == true) &&
|
||||
core.settings.getLastTarget() != Target.thisDevice;
|
||||
}
|
||||
|
||||
@@ -209,11 +207,10 @@ class CoreLogic {
|
||||
|
||||
bool get ignoreWarnings =>
|
||||
core.settings.getTrainerApp()?.supportsZwiftEmulation == true ||
|
||||
core.settings.getTrainerApp()?.supportsOpenBikeProtocol == true;
|
||||
core.settings.getTrainerApp()?.supportsOpenBikeProtocol.isNotEmpty == true;
|
||||
|
||||
bool get showLocalRemoteOptions =>
|
||||
core.actionHandler.supportedModes.isNotEmpty &&
|
||||
((showLocalControl && core.settings.getLocalEnabled()) || (isRemoteControlEnabled));
|
||||
core.actionHandler.supportedModes.isNotEmpty && ((showLocalControl) || (isRemoteControlEnabled));
|
||||
|
||||
bool get hasNoConnectionMethod =>
|
||||
!screenshotMode &&
|
||||
@@ -261,9 +258,7 @@ class CoreLogic {
|
||||
Future<bool> isTrainerConnected() async {
|
||||
if (screenshotMode) {
|
||||
return true;
|
||||
} else if (showLocalControl &&
|
||||
core.settings.getLocalEnabled() &&
|
||||
core.settings.getTrainerApp()?.supportsOpenBikeProtocol == false) {
|
||||
} else if (showLocalControl && core.settings.getLocalEnabled()) {
|
||||
if (canRunAndroidService) {
|
||||
return isAndroidServiceRunning();
|
||||
} else {
|
||||
@@ -336,87 +331,3 @@ class CoreLogic {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MediaKeyHandler {
|
||||
final ValueNotifier<bool> isMediaKeyDetectionEnabled = ValueNotifier(false);
|
||||
|
||||
bool _smtcInitialized = false;
|
||||
SMTCWindows? _smtc;
|
||||
|
||||
void initialize() {
|
||||
isMediaKeyDetectionEnabled.addListener(() async {
|
||||
if (!isMediaKeyDetectionEnabled.value) {
|
||||
if (Platform.isWindows) {
|
||||
_smtc?.disableSmtc();
|
||||
} else {
|
||||
mediaKeyDetector.setIsPlaying(isPlaying: false);
|
||||
mediaKeyDetector.removeListener(_onMediaKeyDetectedListener);
|
||||
}
|
||||
} else {
|
||||
if (Platform.isWindows) {
|
||||
if (!_smtcInitialized) {
|
||||
_smtcInitialized = true;
|
||||
await SMTCWindows.initialize();
|
||||
}
|
||||
|
||||
_smtc = SMTCWindows(
|
||||
metadata: const MusicMetadata(
|
||||
title: 'BikeControl Media Key Handler',
|
||||
album: 'BikeControl',
|
||||
albumArtist: 'BikeControl',
|
||||
artist: 'BikeControl',
|
||||
),
|
||||
// Timeline info for the OS media player
|
||||
timeline: const PlaybackTimeline(
|
||||
startTimeMs: 0,
|
||||
endTimeMs: 1000,
|
||||
positionMs: 0,
|
||||
minSeekTimeMs: 0,
|
||||
maxSeekTimeMs: 1000,
|
||||
),
|
||||
config: const SMTCConfig(
|
||||
fastForwardEnabled: true,
|
||||
nextEnabled: true,
|
||||
pauseEnabled: true,
|
||||
playEnabled: true,
|
||||
rewindEnabled: true,
|
||||
prevEnabled: true,
|
||||
stopEnabled: true,
|
||||
),
|
||||
);
|
||||
_smtc!.buttonPressStream.listen(_onMediaKeyPressedListener);
|
||||
} else {
|
||||
mediaKeyDetector.addListener(_onMediaKeyDetectedListener);
|
||||
mediaKeyDetector.setIsPlaying(isPlaying: true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onMediaKeyDetectedListener(MediaKey mediaKey) {
|
||||
_onMediaKeyPressedListener(switch (mediaKey) {
|
||||
MediaKey.playPause => PressedButton.play,
|
||||
MediaKey.rewind => PressedButton.rewind,
|
||||
MediaKey.fastForward => PressedButton.fastForward,
|
||||
MediaKey.volumeUp => PressedButton.channelUp,
|
||||
MediaKey.volumeDown => PressedButton.channelDown,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onMediaKeyPressedListener(PressedButton mediaKey) async {
|
||||
final hidDevice = HidDevice('HID Device');
|
||||
final keyPressed = mediaKey.name;
|
||||
|
||||
final button = hidDevice.getOrAddButton(
|
||||
keyPressed,
|
||||
() => ControllerButton(keyPressed),
|
||||
);
|
||||
|
||||
var availableDevice = core.connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
|
||||
if (availableDevice == null) {
|
||||
core.connection.addDevices([hidDevice]);
|
||||
availableDevice = hidDevice;
|
||||
}
|
||||
availableDevice.handleButtonsClickedWithoutLongPressSupport([button]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +157,9 @@ 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 '${_revenueCatService?.trialDaysRemaining ?? _iapService?.trialDaysRemaining ?? _windowsIapService?.trialDaysRemaining} day trial available';
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -13,6 +14,7 @@ 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';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
/// RevenueCat-based IAP service for iOS, macOS, and Android
|
||||
class RevenueCatService {
|
||||
@@ -22,6 +24,7 @@ class RevenueCatService {
|
||||
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 _syncedPurchasesKey = 'iap_synced_purchases';
|
||||
|
||||
// RevenueCat entitlement identifier
|
||||
static const String fullVersionEntitlement = 'Full Version';
|
||||
@@ -31,6 +34,7 @@ class RevenueCatService {
|
||||
final int Function() getDailyCommandLimit;
|
||||
final void Function(int limit) setDailyCommandLimit;
|
||||
|
||||
static const _isAndroidWorking = false;
|
||||
bool _isInitialized = false;
|
||||
String? _trialStartDate;
|
||||
String? _lastCommandDate;
|
||||
@@ -115,7 +119,9 @@ class RevenueCatService {
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
if (!isTrialExpired && Platform.isAndroid) {
|
||||
if (Platform.isAndroid && !isPurchasedNotifier.value && !_isAndroidWorking) {
|
||||
setDailyCommandLimit(10000);
|
||||
} else if (!isTrialExpired && Platform.isAndroid) {
|
||||
setDailyCommandLimit(80);
|
||||
}
|
||||
} catch (e, s) {
|
||||
@@ -135,6 +141,11 @@ class RevenueCatService {
|
||||
/// Check if the user has an active entitlement
|
||||
Future<void> _checkExistingPurchase() async {
|
||||
try {
|
||||
final storedStatus = await _prefs.read(key: _syncedPurchasesKey);
|
||||
if (storedStatus != "true") {
|
||||
await _prefs.write(key: _syncedPurchasesKey, value: "true");
|
||||
await Purchases.syncPurchases();
|
||||
}
|
||||
// Check current entitlement status from RevenueCat
|
||||
final customerInfo = await Purchases.getCustomerInfo();
|
||||
await _handleCustomerInfoUpdate(customerInfo);
|
||||
@@ -164,8 +175,14 @@ class RevenueCatService {
|
||||
} else {
|
||||
final purchasedVersion = customerInfo.originalApplicationVersion;
|
||||
core.connection.signalNotification(LogNotification('Apple receipt validated for version: $purchasedVersion'));
|
||||
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 0;
|
||||
isPurchasedNotifier.value = purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58);
|
||||
if (purchasedVersion != null && purchasedVersion.contains(".")) {
|
||||
final parsedVersion = Version.parse(purchasedVersion);
|
||||
isPurchasedNotifier.value = parsedVersion < Version(4, 2, 0) || parsedVersion >= Version(4, 4, 0);
|
||||
} else {
|
||||
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 1337;
|
||||
isPurchasedNotifier.value =
|
||||
purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58) || purchasedVersionAsInt >= 77;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isPurchasedNotifier.value = hasEntitlement;
|
||||
@@ -223,7 +240,14 @@ class RevenueCatService {
|
||||
/// Purchase the full version (use paywall instead)
|
||||
Future<void> purchaseFullVersion(BuildContext context) async {
|
||||
// Direct the user to the paywall for a better experience
|
||||
if (Platform.isMacOS) {
|
||||
if (Platform.isAndroid && !_isAndroidWorking) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
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);
|
||||
@@ -352,7 +376,7 @@ class RevenueCatService {
|
||||
"bikecontrol_target": core.settings.getLastTarget()?.name ?? '-',
|
||||
if (core.connection.controllerDevices.isNotEmpty)
|
||||
'bikecontrol_controllers': core.connection.controllerDevices.joinToString(
|
||||
transform: (d) => d.name,
|
||||
transform: (d) => d.toString(),
|
||||
separator: ',',
|
||||
),
|
||||
'bikecontrol_keymap': core.settings.getKeyMap()?.name ?? '-',
|
||||
|
||||
@@ -4,6 +4,7 @@ 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/utils/windows_store_environment.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
@@ -70,8 +71,11 @@ class WindowsIAPService {
|
||||
trialDaysRemaining = 0;
|
||||
}
|
||||
} else {
|
||||
final isStorePackaged = await WindowsStoreEnvironment.isPackaged();
|
||||
trial.isActive = isStorePackaged;
|
||||
trialDaysRemaining = 0;
|
||||
}
|
||||
|
||||
if (trial.isActive && !trial.isTrial && trialDaysRemaining <= 0) {
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
await _prefs.write(key: _purchaseStatusKey, value: "true");
|
||||
@@ -100,7 +104,7 @@ class WindowsIAPService {
|
||||
}
|
||||
|
||||
/// Check if the trial period has started
|
||||
bool get hasTrialStarted => trialDaysRemaining > 0;
|
||||
bool get hasTrialStarted => trialDaysRemaining >= 0;
|
||||
|
||||
/// Get the number of days remaining in the trial
|
||||
int trialDaysRemaining = 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.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/services.dart';
|
||||
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
@@ -13,7 +13,6 @@ class Biketerra extends SupportedApp {
|
||||
packageName: "biketerra",
|
||||
compatibleTargets: Target.values,
|
||||
supportsZwiftEmulation: true,
|
||||
supportsOpenBikeProtocol: false,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
...ControllerButton.values
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
@@ -14,7 +13,6 @@ class MyWhoosh extends SupportedApp {
|
||||
packageName: "com.mywhoosh.whooshgame",
|
||||
compatibleTargets: Target.values,
|
||||
supportsZwiftEmulation: false,
|
||||
supportsOpenBikeProtocol: screenshotMode,
|
||||
star: true,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
|
||||
@@ -10,7 +10,7 @@ class OpenBikeControl extends SupportedApp {
|
||||
packageName: "org.openbikecontrol",
|
||||
compatibleTargets: Target.values,
|
||||
supportsZwiftEmulation: false,
|
||||
supportsOpenBikeProtocol: true,
|
||||
supportsOpenBikeProtocol: OpenBikeProtocolSupport.values,
|
||||
keymap: Keymap(
|
||||
keyPairs: [],
|
||||
),
|
||||
|
||||
@@ -9,13 +9,18 @@ import '../keymap.dart';
|
||||
import 'custom_app.dart';
|
||||
import 'my_whoosh.dart';
|
||||
|
||||
enum OpenBikeProtocolSupport {
|
||||
ble,
|
||||
network,
|
||||
}
|
||||
|
||||
abstract class SupportedApp {
|
||||
final List<Target> compatibleTargets;
|
||||
final String packageName;
|
||||
final String name;
|
||||
final Keymap keymap;
|
||||
final bool supportsZwiftEmulation;
|
||||
final bool supportsOpenBikeProtocol;
|
||||
final List<OpenBikeProtocolSupport> supportsOpenBikeProtocol;
|
||||
final bool star;
|
||||
|
||||
const SupportedApp({
|
||||
@@ -24,7 +29,7 @@ abstract class SupportedApp {
|
||||
required this.keymap,
|
||||
required this.compatibleTargets,
|
||||
required this.supportsZwiftEmulation,
|
||||
this.supportsOpenBikeProtocol = false,
|
||||
this.supportsOpenBikeProtocol = const [],
|
||||
this.star = false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
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';
|
||||
|
||||
class TrainingPeaks extends SupportedApp {
|
||||
TrainingPeaks()
|
||||
: super(
|
||||
name: 'TrainingPeaks Virtual / IndieVelo',
|
||||
name: 'TrainingPeaks Virtual',
|
||||
packageName: "com.indieVelo.client",
|
||||
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
|
||||
|
||||
@@ -37,7 +37,9 @@ enum InGameAction {
|
||||
|
||||
// openbikecontrol
|
||||
up('Up', icon: RadixIcons.arrowUp),
|
||||
down('Down', icon: RadixIcons.arrowDown);
|
||||
down('Down', icon: RadixIcons.arrowDown),
|
||||
home('Home', icon: RadixIcons.home),
|
||||
menu('Menu', icon: RadixIcons.dropdownMenu);
|
||||
|
||||
final String title;
|
||||
final bool isLongPress;
|
||||
@@ -54,11 +56,14 @@ enum InGameAction {
|
||||
}
|
||||
|
||||
class ControllerButton {
|
||||
static const int _deviceIdSuffixLength = 4;
|
||||
static const _unset = Object();
|
||||
final String name;
|
||||
final int? identifier;
|
||||
final InGameAction? action;
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
final String? sourceDeviceId;
|
||||
|
||||
const ControllerButton(
|
||||
this.name, {
|
||||
@@ -66,8 +71,40 @@ class ControllerButton {
|
||||
this.icon,
|
||||
this.identifier,
|
||||
this.action,
|
||||
this.sourceDeviceId,
|
||||
});
|
||||
|
||||
ControllerButton copyWith({
|
||||
String? name,
|
||||
int? identifier,
|
||||
InGameAction? action,
|
||||
Color? color,
|
||||
IconData? icon,
|
||||
Object? sourceDeviceId = _unset,
|
||||
}) {
|
||||
final newSourceDeviceId = sourceDeviceId == _unset ? this.sourceDeviceId : sourceDeviceId as String?;
|
||||
|
||||
return ControllerButton(
|
||||
name ?? this.name,
|
||||
color: color ?? this.color,
|
||||
icon: icon ?? this.icon,
|
||||
identifier: identifier ?? this.identifier,
|
||||
action: action ?? this.action,
|
||||
sourceDeviceId: newSourceDeviceId,
|
||||
);
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
if (sourceDeviceId == null) {
|
||||
return name;
|
||||
}
|
||||
|
||||
final shortenedId = sourceDeviceId!.length <= _deviceIdSuffixLength
|
||||
? sourceDeviceId!
|
||||
: sourceDeviceId!.substring(sourceDeviceId!.length - _deviceIdSuffixLength);
|
||||
return '$name (${shortenedId.toUpperCase()})';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name;
|
||||
@@ -82,10 +119,11 @@ class ControllerButton {
|
||||
identifier == other.identifier &&
|
||||
action == other.action &&
|
||||
color == other.color &&
|
||||
icon == other.icon;
|
||||
icon == other.icon &&
|
||||
sourceDeviceId == other.sourceDeviceId;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(name, action, identifier, color, icon);
|
||||
int get hashCode => Object.hash(name, action, identifier, color, icon, sourceDeviceId);
|
||||
|
||||
static List<ControllerButton> get values => [
|
||||
...SterzoButtons.values,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
@@ -13,6 +14,23 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import '../actions/base_actions.dart';
|
||||
import 'apps/custom_app.dart';
|
||||
|
||||
enum AndroidSystemAction {
|
||||
back('Back', Icons.arrow_back_ios, GlobalAction.back),
|
||||
dpadCenter('Select', Icons.radio_button_checked_outlined, GlobalAction.dpadCenter),
|
||||
down('Arrow Down', Icons.arrow_downward, GlobalAction.down),
|
||||
right('Arrow Right', Icons.arrow_forward, GlobalAction.right),
|
||||
up('Arrow Up', Icons.arrow_upward, GlobalAction.up),
|
||||
left('Arrow Left', Icons.arrow_back, GlobalAction.left),
|
||||
home('Home', Icons.home_outlined, GlobalAction.home),
|
||||
recents('Recents', Icons.apps, GlobalAction.recents);
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final GlobalAction globalAction;
|
||||
|
||||
const AndroidSystemAction(this.title, this.icon, this.globalAction);
|
||||
}
|
||||
|
||||
class Keymap {
|
||||
static Keymap custom = Keymap(keyPairs: []);
|
||||
|
||||
@@ -50,6 +68,7 @@ class Keymap {
|
||||
keyPair.isLongPress = false;
|
||||
keyPair.inGameAction = null;
|
||||
keyPair.inGameActionValue = null;
|
||||
keyPair.androidAction = null;
|
||||
}
|
||||
_updateStream.add(null);
|
||||
}
|
||||
@@ -97,6 +116,10 @@ class Keymap {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void signalUpdate() {
|
||||
_updateStream.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
class KeyPair {
|
||||
@@ -108,6 +131,7 @@ class KeyPair {
|
||||
bool isLongPress;
|
||||
InGameAction? inGameAction;
|
||||
int? inGameActionValue;
|
||||
AndroidSystemAction? androidAction;
|
||||
|
||||
KeyPair({
|
||||
required this.buttons,
|
||||
@@ -118,6 +142,7 @@ class KeyPair {
|
||||
this.isLongPress = false,
|
||||
this.inGameAction,
|
||||
this.inGameActionValue,
|
||||
this.androidAction,
|
||||
});
|
||||
|
||||
bool get isSpecialKey =>
|
||||
@@ -140,8 +165,14 @@ class KeyPair {
|
||||
_ => Icons.keyboard,
|
||||
},
|
||||
//_ when inGameAction != null && core.logic.emulatorEnabled => Icons.link,
|
||||
_ when inGameAction != null && inGameAction!.icon != null => inGameAction!.icon,
|
||||
_ when inGameAction != null && inGameAction!.icon != null && core.logic.emulatorEnabled => inGameAction!.icon,
|
||||
|
||||
_
|
||||
when androidAction != null &&
|
||||
core.logic.showLocalControl &&
|
||||
core.settings.getLocalEnabled() &&
|
||||
core.actionHandler is AndroidActions =>
|
||||
androidAction!.icon,
|
||||
_ when physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard) =>
|
||||
RadixIcons.keyboard,
|
||||
_
|
||||
@@ -155,7 +186,11 @@ class KeyPair {
|
||||
}
|
||||
|
||||
bool get hasNoAction =>
|
||||
logicalKey == null && physicalKey == null && touchPosition == Offset.zero && inGameAction == null;
|
||||
logicalKey == null &&
|
||||
physicalKey == null &&
|
||||
touchPosition == Offset.zero &&
|
||||
inGameAction == null &&
|
||||
androidAction == null;
|
||||
|
||||
bool get hasActiveAction =>
|
||||
screenshotMode ||
|
||||
@@ -163,6 +198,14 @@ class KeyPair {
|
||||
core.logic.showLocalControl &&
|
||||
core.settings.getLocalEnabled() &&
|
||||
core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) ||
|
||||
(isSpecialKey &&
|
||||
core.logic.showLocalControl &&
|
||||
core.settings.getLocalEnabled() &&
|
||||
core.actionHandler is AndroidActions) ||
|
||||
(androidAction != null &&
|
||||
core.logic.showLocalControl &&
|
||||
core.settings.getLocalEnabled() &&
|
||||
core.actionHandler is AndroidActions) ||
|
||||
(touchPosition != Offset.zero &&
|
||||
core.logic.showLocalRemoteOptions &&
|
||||
core.actionHandler.supportedModes.contains(SupportedMode.touch)) ||
|
||||
@@ -189,6 +232,8 @@ class KeyPair {
|
||||
inGameAction!.title,
|
||||
if (inGameActionValue != null) '$inGameActionValue',
|
||||
].joinToString(separator: ': ')
|
||||
: (androidAction != null && core.logic.showLocalControl && core.actionHandler is AndroidActions)
|
||||
? androidAction!.title
|
||||
: (isSpecialKey && core.actionHandler.supportedModes.contains(SupportedMode.media))
|
||||
? switch (physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => AppLocalizations.current.playPause,
|
||||
@@ -235,7 +280,16 @@ class KeyPair {
|
||||
// encode to save in preferences
|
||||
|
||||
return jsonEncode({
|
||||
'actions': buttons.map((e) => e.name).toList(),
|
||||
'actions': buttons
|
||||
.map(
|
||||
(e) => e.sourceDeviceId == null
|
||||
? e.name
|
||||
: {
|
||||
'name': e.name,
|
||||
'deviceId': e.sourceDeviceId,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
if (logicalKey != null) 'logicalKey': logicalKey?.keyId.toString(),
|
||||
if (physicalKey != null) 'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0',
|
||||
if (modifiers.isNotEmpty) 'modifiers': modifiers.map((e) => e.name).toList(),
|
||||
@@ -243,6 +297,7 @@ class KeyPair {
|
||||
'isLongPress': isLongPress,
|
||||
'inGameAction': inGameAction?.name,
|
||||
'inGameActionValue': inGameActionValue,
|
||||
'androidAction': androidAction?.name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,11 +313,33 @@ class KeyPair {
|
||||
)
|
||||
: Offset.zero;
|
||||
|
||||
final buttons = decoded['actions']
|
||||
.map<ControllerButton>(
|
||||
(e) => ControllerButton.values.firstOrNullWhere((element) => element.name == e) ?? ControllerButton(e),
|
||||
)
|
||||
.cast<ControllerButton>()
|
||||
ControllerButton? decodeButton(dynamic raw) {
|
||||
String? name;
|
||||
String? deviceId;
|
||||
|
||||
if (raw is String) {
|
||||
name = raw;
|
||||
} else if (raw is Map) {
|
||||
name = raw['name']?.toString();
|
||||
deviceId = raw['deviceId']?.toString();
|
||||
}
|
||||
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final baseButton = ControllerButton.values.firstOrNullWhere((element) => element.name == name);
|
||||
|
||||
if (baseButton != null) {
|
||||
return baseButton.copyWith(sourceDeviceId: deviceId);
|
||||
}
|
||||
|
||||
return ControllerButton(name, sourceDeviceId: deviceId);
|
||||
}
|
||||
|
||||
final buttons = (decoded['actions'] as List)
|
||||
.map<ControllerButton?>(decodeButton)
|
||||
.whereType<ControllerButton>()
|
||||
.toList();
|
||||
if (buttons.isEmpty) {
|
||||
return null;
|
||||
@@ -291,6 +368,9 @@ class KeyPair {
|
||||
? InGameAction.values.firstOrNullWhere((element) => element.name == decoded['inGameAction'])
|
||||
: null,
|
||||
inGameActionValue: decoded['inGameActionValue'],
|
||||
androidAction: decoded.containsKey('androidAction')
|
||||
? AndroidSystemAction.values.firstOrNullWhere((element) => element.name == decoded['androidAction'])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -305,7 +385,8 @@ class KeyPair {
|
||||
touchPosition == other.touchPosition &&
|
||||
isLongPress == other.isLongPress &&
|
||||
inGameAction == other.inGameAction &&
|
||||
inGameActionValue == other.inGameActionValue;
|
||||
inGameActionValue == other.inGameActionValue &&
|
||||
androidAction == other.androidAction;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
@@ -316,5 +397,6 @@ class KeyPair {
|
||||
isLongPress,
|
||||
inGameAction,
|
||||
inGameActionValue,
|
||||
androidAction,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
127
lib/utils/media_key_handler.dart
Normal file
127
lib/utils/media_key_handler.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||
import 'package:media_key_detector/media_key_detector.dart';
|
||||
|
||||
import 'smtc_stub.dart' if (dart.library.io) 'package:smtc_windows/smtc_windows.dart';
|
||||
|
||||
class MediaKeyHandler {
|
||||
final ValueNotifier<bool> isMediaKeyDetectionEnabled = ValueNotifier(false);
|
||||
|
||||
bool _smtcInitialized = false;
|
||||
double? _lastVolume;
|
||||
SMTCWindows? _smtc;
|
||||
|
||||
void initialize() {
|
||||
isMediaKeyDetectionEnabled.addListener(() async {
|
||||
if (!isMediaKeyDetectionEnabled.value) {
|
||||
FlutterVolumeController.removeListener();
|
||||
if (Platform.isWindows) {
|
||||
_smtc?.disableSmtc();
|
||||
} else {
|
||||
mediaKeyDetector.setIsPlaying(isPlaying: false);
|
||||
mediaKeyDetector.removeListener(_onMediaKeyDetectedListener);
|
||||
}
|
||||
} else {
|
||||
FlutterVolumeController.addListener(
|
||||
(volume) {
|
||||
_lastVolume ??= volume;
|
||||
if (volume != _lastVolume) {
|
||||
final bool hasAction;
|
||||
if (volume > _lastVolume!) {
|
||||
hasAction = _onMediaKeyDetectedListener(MediaKey.volumeUp);
|
||||
} else {
|
||||
hasAction = _onMediaKeyDetectedListener(MediaKey.volumeDown);
|
||||
}
|
||||
if (hasAction) {
|
||||
// revert volume
|
||||
FlutterVolumeController.setVolume(_lastVolume!);
|
||||
} else {
|
||||
_lastVolume = volume;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if (Platform.isWindows) {
|
||||
if (!_smtcInitialized) {
|
||||
_smtcInitialized = true;
|
||||
await SMTCWindows.initialize();
|
||||
}
|
||||
|
||||
_smtc = SMTCWindows(
|
||||
metadata: const MusicMetadata(
|
||||
title: 'BikeControl Media Key Handler',
|
||||
album: 'BikeControl',
|
||||
albumArtist: 'BikeControl',
|
||||
artist: 'BikeControl',
|
||||
),
|
||||
// Timeline info for the OS media player
|
||||
timeline: const PlaybackTimeline(
|
||||
startTimeMs: 0,
|
||||
endTimeMs: 1000,
|
||||
positionMs: 0,
|
||||
minSeekTimeMs: 0,
|
||||
maxSeekTimeMs: 1000,
|
||||
),
|
||||
config: const SMTCConfig(
|
||||
fastForwardEnabled: true,
|
||||
nextEnabled: true,
|
||||
pauseEnabled: true,
|
||||
playEnabled: true,
|
||||
rewindEnabled: true,
|
||||
prevEnabled: true,
|
||||
stopEnabled: true,
|
||||
),
|
||||
);
|
||||
_smtc!.buttonPressStream.listen(_onMediaKeyPressedListener);
|
||||
} else {
|
||||
mediaKeyDetector.addListener(_onMediaKeyDetectedListener);
|
||||
mediaKeyDetector.setIsPlaying(isPlaying: true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _onMediaKeyDetectedListener(MediaKey mediaKey) {
|
||||
final hidDevice = HidDevice('HID Device');
|
||||
|
||||
var availableDevice = core.connection.controllerDevices.firstOrNullWhere(
|
||||
(e) => e.toString() == hidDevice.toString(),
|
||||
);
|
||||
if (availableDevice == null) {
|
||||
core.connection.addDevices([hidDevice]);
|
||||
availableDevice = hidDevice;
|
||||
}
|
||||
|
||||
final keyPressed = mediaKey.name;
|
||||
|
||||
final button = availableDevice.getOrAddButton(
|
||||
keyPressed,
|
||||
() => ControllerButton(keyPressed),
|
||||
);
|
||||
|
||||
availableDevice.handleButtonsClickedWithoutLongPressSupport([button]);
|
||||
|
||||
return core.actionHandler.supportedApp?.keymap.getKeyPair(button)?.hasNoAction == false;
|
||||
}
|
||||
|
||||
bool _onMediaKeyPressedListener(PressedButton mediaKey) {
|
||||
return _onMediaKeyDetectedListener(switch (mediaKey) {
|
||||
PressedButton.play => MediaKey.playPause,
|
||||
PressedButton.pause => MediaKey.playPause,
|
||||
PressedButton.next => MediaKey.fastForward,
|
||||
PressedButton.previous => MediaKey.rewind,
|
||||
PressedButton.stop => MediaKey.playPause,
|
||||
PressedButton.fastForward => MediaKey.fastForward,
|
||||
PressedButton.rewind => MediaKey.rewind,
|
||||
PressedButton.record => throw UnimplementedError(),
|
||||
PressedButton.channelUp => MediaKey.volumeUp,
|
||||
PressedButton.channelDown => MediaKey.volumeDown,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
@@ -22,6 +23,7 @@ class AccessibilityRequirement extends PlatformRequirement {
|
||||
: super(
|
||||
AppLocalizations.current.allowAccessibilityService,
|
||||
description: AppLocalizations.current.accessibilityDescription,
|
||||
icon: Icons.accessibility_new,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -62,7 +64,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 {
|
||||
@@ -79,7 +81,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 {
|
||||
@@ -96,7 +98,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,18 +115,28 @@ class BluetoothConnectRequirement extends PlatformRequirement {
|
||||
}
|
||||
}
|
||||
|
||||
ReceivePort? _receivePort;
|
||||
StreamSubscription? _sub;
|
||||
|
||||
class NotificationRequirement extends PlatformRequirement {
|
||||
NotificationRequirement()
|
||||
: 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) {
|
||||
final result = await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
|
||||
@@ -132,6 +145,7 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
badge: false,
|
||||
sound: false,
|
||||
);
|
||||
core.settings.setHasAskedPermissions(true);
|
||||
if (result == false) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
@@ -147,6 +161,7 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
badge: false,
|
||||
sound: false,
|
||||
);
|
||||
core.settings.setHasAskedPermissions(true);
|
||||
if (result == false) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
@@ -172,31 +187,29 @@ 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;
|
||||
}
|
||||
if (status) {
|
||||
await setup();
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
static Future<void> setup() async {
|
||||
const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
print('NOTIFICATION SETUP');
|
||||
await core.flutterLocalNotificationsPlugin.initialize(
|
||||
InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
android: AndroidInitializationSettings(
|
||||
'@drawable/ic_notification',
|
||||
),
|
||||
iOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
),
|
||||
macOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
@@ -261,10 +274,17 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
),
|
||||
);
|
||||
|
||||
final receivePort = ReceivePort();
|
||||
IsolateNameServer.registerPortWithName(receivePort.sendPort, '_backgroundChannelKey');
|
||||
final backgroundMessagePort = receivePort.asBroadcastStream();
|
||||
backgroundMessagePort.listen((message) {
|
||||
_receivePort = ReceivePort();
|
||||
// If already registered, remove and re-register
|
||||
IsolateNameServer.removePortNameMapping('_backgroundChannelKey');
|
||||
final ok = IsolateNameServer.registerPortWithName(_receivePort!.sendPort, '_backgroundChannelKey');
|
||||
if (!ok) {
|
||||
// If this happens, something else re-registered immediately or you’re in a weird state.
|
||||
throw StateError('Failed to register port name');
|
||||
}
|
||||
final backgroundMessagePort = _receivePort!.asBroadcastStream();
|
||||
_sub = backgroundMessagePort.listen((message) {
|
||||
print('Background isolate received message: $message');
|
||||
if (message == 'disconnect' || message == 'close') {
|
||||
UniversalBle.onAvailabilityChange = null;
|
||||
core.connection.disconnectAll();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -163,13 +165,13 @@ enum Target {
|
||||
|
||||
return switch (this) {
|
||||
Target.thisDevice => false,
|
||||
_ => supportedApp == null || supportedApp.supportsOpenBikeProtocol == false,
|
||||
_ => supportedApp == null || supportedApp.supportsOpenBikeProtocol.isEmpty,
|
||||
};
|
||||
}
|
||||
|
||||
String getDescription(SupportedApp? app) {
|
||||
final appName = app?.name ?? 'the Trainer app';
|
||||
final preferredConnectionMethod = app?.supportsOpenBikeProtocol == true
|
||||
final preferredConnectionMethod = app?.supportsOpenBikeProtocol.isNotEmpty == true
|
||||
? AppLocalizations.current.openBikeControlConnection
|
||||
: app is MyWhoosh
|
||||
? AppLocalizations.current.myWhooshDirectConnection
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
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/android.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';
|
||||
@@ -24,8 +26,19 @@ class Settings {
|
||||
Future<String?> init({bool retried = false}) async {
|
||||
try {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
try {
|
||||
await NotificationRequirement.setup();
|
||||
} catch (error, stack) {
|
||||
recordError(error, stack, context: 'Notification setup');
|
||||
}
|
||||
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();
|
||||
@@ -375,4 +388,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);
|
||||
}
|
||||
}
|
||||
|
||||
36
lib/utils/windows_store_environment.dart
Normal file
36
lib/utils/windows_store_environment.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Utilities for determining whether the app is running in a Windows Store/MSIX
|
||||
/// packaged context.
|
||||
///
|
||||
/// Security note:
|
||||
/// - Build-time secrets (e.g. --dart-define) are extractable from the binary.
|
||||
/// - This check is meant to answer a platform truth: "Is this app packaged?"
|
||||
/// - For real entitlement enforcement, rely on Store license APIs (and
|
||||
/// optionally a backend), not on compile-time flags.
|
||||
class WindowsStoreEnvironment {
|
||||
static const MethodChannel _channel = MethodChannel('bike_control/store_env');
|
||||
|
||||
/// Debug-only escape hatch to simulate Store packaged mode.
|
||||
///
|
||||
/// This is intentionally *not* a security feature.
|
||||
static const bool _forcePackagedForDebug = bool.fromEnvironment('FORCE_STORE_PACKAGED', defaultValue: false);
|
||||
|
||||
/// Returns true when running as an MSIX/Store packaged app.
|
||||
///
|
||||
/// In debug/profile you may set `--dart-define=FORCE_STORE_PACKAGED=true`
|
||||
/// for local testing.
|
||||
static Future<bool> isPackaged() async {
|
||||
if (!kReleaseMode && _forcePackagedForDebug) return true;
|
||||
|
||||
try {
|
||||
final result = await _channel.invokeMethod<bool>('isPackaged');
|
||||
return result ?? false;
|
||||
} catch (_) {
|
||||
// If the platform implementation isn't present (e.g., non-Windows),
|
||||
// treat as unpackaged.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,9 +189,11 @@ class _LocalTileState extends State<LocalTile> {
|
||||
),
|
||||
];
|
||||
return ConnectionMethod(
|
||||
supportedActions: null,
|
||||
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()),
|
||||
|
||||
@@ -25,6 +25,7 @@ class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
|
||||
valueListenable: core.whooshLink.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: core.whooshLink.supportedActions,
|
||||
isEnabled: core.settings.getMyWhooshLinkEnabled(),
|
||||
type: ConnectionMethodType.network,
|
||||
title: context.i18n.connectUsingMyWhooshLink,
|
||||
@@ -42,17 +43,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 {
|
||||
@@ -23,11 +24,14 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlBluetoothTile> {
|
||||
valueListenable: core.obpBluetoothEmulator.connectedApp,
|
||||
builder: (context, isConnected, _) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: isConnected?.supportedActions,
|
||||
isEnabled: core.settings.getObpBleEnabled(),
|
||||
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 {
|
||||
@@ -23,12 +24,15 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlMdnsTile> {
|
||||
valueListenable: core.obpMdnsEmulator.connectedApp,
|
||||
builder: (context, isConnected, _) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: isConnected?.supportedActions,
|
||||
isEnabled: core.settings.getObpMdnsEnabled(),
|
||||
type: ConnectionMethodType.openBikeControl,
|
||||
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 ?? ''),
|
||||
|
||||
@@ -27,6 +27,7 @@ class _ZwiftTileState extends State<ZwiftMdnsTile> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: core.zwiftMdnsEmulator.supportedActions,
|
||||
type: ConnectionMethodType.network,
|
||||
isEnabled: core.settings.getZwiftMdnsEmulatorEnabled(),
|
||||
title: context.i18n.enableZwiftControllerNetwork,
|
||||
|
||||
@@ -27,6 +27,7 @@ class _ZwiftTileState extends State<ZwiftTile> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: core.zwiftEmulator.supportedActions,
|
||||
isEnabled: core.settings.getZwiftBleEmulatorEnabled(),
|
||||
type: ConnectionMethodType.bluetooth,
|
||||
instructionLink: 'INSTRUCTIONS_ZWIFT.md',
|
||||
|
||||
@@ -22,13 +22,20 @@ class ChangelogDialog extends StatelessWidget {
|
||||
children: [
|
||||
Text(context.i18n.whatsNew),
|
||||
SizedBox(height: 4),
|
||||
Text(context.i18n.version(entry.blocks.first.text)),
|
||||
Text(context.i18n.version(entry.blocks.first.text)).small,
|
||||
],
|
||||
),
|
||||
content: Container(
|
||||
constraints: BoxConstraints(minWidth: 460, maxHeight: 500),
|
||||
child: Scrollbar(
|
||||
child: SingleChildScrollView(child: MarkdownWidget(markdown: latestVersion)),
|
||||
child: SingleChildScrollView(
|
||||
child: MarkdownWidget(
|
||||
markdown: latestVersion,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
@@ -42,7 +49,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) {
|
||||
|
||||
@@ -3,12 +3,14 @@ import 'dart:io';
|
||||
|
||||
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/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';
|
||||
@@ -24,7 +26,7 @@ class IAPStatusWidget extends StatefulWidget {
|
||||
State<IAPStatusWidget> createState() => _IAPStatusWidgetState();
|
||||
}
|
||||
|
||||
final _normalDate = DateTime(2026, 1, 15, 0, 0, 0, 0, 0);
|
||||
final _normalDate = DateTime(2026, 2, 15, 0, 0, 0, 0, 0);
|
||||
final _iapDate = DateTime(2025, 12, 21, 0, 0, 0, 0, 0);
|
||||
|
||||
enum AlreadyBoughtOption { fullPurchase, iap, no }
|
||||
@@ -65,383 +67,395 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
|
||||
final commandsRemaining = iapManager.commandsRemainingToday;
|
||||
final dailyCommandCount = iapManager.dailyCommandCount;
|
||||
|
||||
return 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: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
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(
|
||||
],
|
||||
),
|
||||
] 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: [
|
||||
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.****-****-****-*****'),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
FormErrorBuilder(
|
||||
builder: (context, errors, child) {
|
||||
return PrimaryButton(
|
||||
onPressed: errors.isEmpty ? () => context.submitForm() : null,
|
||||
child: _isLoading
|
||||
? SmallProgressIndicator(color: Colors.black)
|
||||
: const Text('Submit'),
|
||||
],
|
||||
),
|
||||
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 ||
|
||||
DateTime.now().isAfter(_normalDate)) ...[
|
||||
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 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
)
|
||||
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 (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,
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase(BuildContext context) async {
|
||||
@@ -455,7 +469,7 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
buildToast(
|
||||
context,
|
||||
navigatorKey.currentContext!,
|
||||
title: 'Error',
|
||||
subtitle: 'An error occurred: $e',
|
||||
);
|
||||
|
||||
@@ -49,6 +49,7 @@ class _KeymapExplanationState extends State<KeymapExplanation> {
|
||||
if (keyPair != null && !_isDrawerOpen) {
|
||||
await _openKeyPairEditor(keyPair);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -78,7 +79,11 @@ class _KeymapExplanationState extends State<KeymapExplanation> {
|
||||
.whereNot(
|
||||
(keyPair) => keyPair.buttons.filter((b) => allAvailableButtons.contains(b)).isEmpty,
|
||||
)
|
||||
.sortedBy((k) => k.buttons.first.color != null ? 0 : 1);
|
||||
.sortedBy(
|
||||
(k) => k.buttons.first.color != null
|
||||
? '0${(k.buttons.first.icon?.codePoint ?? 0 * -1)}'
|
||||
: '1${(k.buttons.first.icon?.codePoint ?? 0 * -1)}',
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -172,6 +177,7 @@ class _KeymapExplanationState extends State<KeymapExplanation> {
|
||||
keyPair: selectedKeyPair,
|
||||
keymap: widget.keymap,
|
||||
onUpdate: () {
|
||||
widget.keymap.signalUpdate();
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
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/foundation.dart';
|
||||
import 'package:flutter/material.dart' show SelectionArea;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
@@ -76,40 +79,54 @@ 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('Logs file: '),
|
||||
SelectableText('${Directory.current.path}/app.log').inlineCode,
|
||||
],
|
||||
).small,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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}')}
|
||||
''';
|
||||
@@ -294,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,11 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart' show LogLevel;
|
||||
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/widgets/ui/connection_method.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../utils/requirements/multi.dart';
|
||||
|
||||
@@ -26,6 +26,7 @@ class _PairWidgetState extends State<RemotePairingWidget> {
|
||||
valueListenable: core.remotePairing.isConnected,
|
||||
builder: (context, isConnected, child) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: null,
|
||||
isEnabled: core.logic.isRemoteControlEnabled,
|
||||
isStarted: isStarted,
|
||||
showTroubleshooting: true,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -92,7 +92,18 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
|
||||
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;
|
||||
@@ -224,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(
|
||||
@@ -260,7 +248,6 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
|
||||
autofocus: true,
|
||||
canRequestFocus: true,
|
||||
descendantsAreFocusable: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
@@ -280,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;
|
||||
@@ -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: () {
|
||||
core.connection.disconnectAll();
|
||||
core.connection.stop();
|
||||
if (Platform.isIOS) {
|
||||
Restart.restartApp(delayBeforeRestart: 1000);
|
||||
} else {
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class ButtonWidget extends StatelessWidget {
|
||||
size: big && button.color != null ? null : 14,
|
||||
)
|
||||
: Text(
|
||||
button.name.splitByUpperCase(),
|
||||
button.displayName.splitByUpperCase(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: screenshotMode ? null : 'monospace',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/button_edit.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.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';
|
||||
@@ -28,6 +31,7 @@ class ConnectionMethod extends StatefulWidget {
|
||||
final bool isEnabled;
|
||||
final bool showTroubleshooting;
|
||||
final List<PlatformRequirement> requirements;
|
||||
final List<InGameAction>? supportedActions;
|
||||
final Function(bool) onChange;
|
||||
|
||||
const ConnectionMethod({
|
||||
@@ -40,6 +44,7 @@ class ConnectionMethod extends StatefulWidget {
|
||||
this.instructionLink,
|
||||
this.showTroubleshooting = false,
|
||||
required this.onChange,
|
||||
required this.supportedActions,
|
||||
required this.requirements,
|
||||
this.isConnected,
|
||||
this.isStarted,
|
||||
@@ -150,35 +155,71 @@ class _ConnectionMethodState extends State<ConnectionMethod> with WidgetsBinding
|
||||
if (widget.isEnabled && widget.additionalChild != null) widget.additionalChild!,
|
||||
if (widget.instructionLink != null || widget.showTroubleshooting) SizedBox(height: 8),
|
||||
if (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: (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'),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
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: () {
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (c) => MarkdownPage(assetPath: widget.instructionLink!),
|
||||
);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context).instructions),
|
||||
),
|
||||
if (widget.supportedActions != null)
|
||||
Button.outline(
|
||||
leading: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
margin: EdgeInsets.only(right: 4),
|
||||
child: Text(
|
||||
widget.supportedActions!.length.toString(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.right,
|
||||
builder: (c) => Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 32, horizontal: 16),
|
||||
width: 230,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
ColoredTitle(
|
||||
text: AppLocalizations.of(context).supportedActions,
|
||||
),
|
||||
Gap(12),
|
||||
...widget.supportedActions!.map(
|
||||
(e) => Basic(
|
||||
leading: e.icon != null ? Icon(e.icon) : null,
|
||||
title: Text(e.title),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context).supportedActions),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -200,94 +241,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <bluetooth_low_energy_linux/bluetooth_low_energy_linux_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <flutter_volume_controller/flutter_volume_controller_plugin.h>
|
||||
#include <gamepads_linux/gamepads_linux_plugin.h>
|
||||
#include <gtk/gtk_plugin.h>
|
||||
#include <media_key_detector_linux/media_key_detector_plugin.h>
|
||||
@@ -27,6 +28,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_volume_controller_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterVolumeControllerPlugin");
|
||||
flutter_volume_controller_plugin_register_with_registrar(flutter_volume_controller_registrar);
|
||||
g_autoptr(FlPluginRegistrar) gamepads_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "GamepadsLinuxPlugin");
|
||||
gamepads_linux_plugin_register_with_registrar(gamepads_linux_registrar);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user