mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1ffec37d | ||
|
|
9ea4f7157a | ||
|
|
a9b43bd347 | ||
|
|
b9ac193e77 | ||
|
|
1d4947b3ae | ||
|
|
0339089972 | ||
|
|
1e8bd61264 | ||
|
|
79613bc8de | ||
|
|
d0ec785e32 | ||
|
|
020b91fd21 | ||
|
|
f2406152fd | ||
|
|
ab3ef7be53 | ||
|
|
bb7484ff2e | ||
|
|
80061fd076 | ||
|
|
124e005fb1 | ||
|
|
8e760ef202 | ||
|
|
de740f6453 | ||
|
|
3bde90ae62 | ||
|
|
aee8dc2e07 | ||
|
|
b3952542f8 | ||
|
|
910f23a3f6 | ||
|
|
5cc9ac85af | ||
|
|
e10e22d038 | ||
|
|
aaeaec36a2 | ||
|
|
c16a593f3c | ||
|
|
50d9f47576 | ||
|
|
a53fb578ef | ||
|
|
1f89859a03 | ||
|
|
7c1cee6748 | ||
|
|
a4949ad615 | ||
|
|
a57f4654b0 | ||
|
|
8192d3addf | ||
|
|
69f47fa984 | ||
|
|
2b106fd1c9 | ||
|
|
1784e008ee | ||
|
|
33ccdbd7af | ||
|
|
d15f1ddc13 | ||
|
|
e7ea01cd60 | ||
|
|
f7ed426441 | ||
|
|
d30485b82e | ||
|
|
4e646ab922 | ||
|
|
4183ede58d | ||
|
|
dd85e99e4b | ||
|
|
2334a88452 | ||
|
|
ee64b18f75 | ||
|
|
647dac9e7c | ||
|
|
df2496eb67 | ||
|
|
859424b895 | ||
|
|
dde3f38bde | ||
|
|
01744c258e | ||
|
|
231aadbc27 | ||
|
|
a806a628bd | ||
|
|
c529fee1fa | ||
|
|
c36a0252e6 | ||
|
|
66486ec38e |
27
.github/workflows/build.yml
vendored
27
.github/workflows/build.yml
vendored
@@ -152,6 +152,12 @@ jobs:
|
||||
mkdir -p whatsnew
|
||||
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
|
||||
|
||||
- name: Generate release body
|
||||
if: inputs.build_github
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
- name: 🚀 Shorebird Release iOS
|
||||
if: inputs.build_ios
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
@@ -248,7 +254,7 @@ jobs:
|
||||
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
allowUpdates: true
|
||||
prerelease: true
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
bodyFile: /tmp/release_body.md
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
@@ -263,6 +269,15 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
|
||||
- name: Extract version from pubspec.yaml (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
|
||||
($_ -split ' ')[1].Trim()
|
||||
}
|
||||
echo "VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
@@ -332,21 +347,11 @@ jobs:
|
||||
build/windows/x64/runner/Release/SwiftControl.windows.zip
|
||||
build/windows/x64/runner/Release/SwiftControl.windows.msix
|
||||
|
||||
- name: Extract version from pubspec.yaml (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
|
||||
($_ -split ' ')[1].Trim()
|
||||
}
|
||||
echo "VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
|
||||
- name: Update Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip,build/windows/x64/runner/Release/SwiftControl.windows.msix"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
15
.github/workflows/patch.yml
vendored
15
.github/workflows/patch.yml
vendored
@@ -9,6 +9,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: false
|
||||
name: Patch iOS, Android & macOS
|
||||
runs-on: macos-latest
|
||||
|
||||
@@ -125,13 +126,18 @@ jobs:
|
||||
path: |
|
||||
build/macos/Build/Products/Release/SwiftControl.macos.zip
|
||||
|
||||
- name: Generate release body
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
# add artifact to release
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
bodyFile: /tmp/release_body.md
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
@@ -145,13 +151,6 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
#2 Setup Java
|
||||
- name: Set Up Java
|
||||
uses: actions/setup-java@v3.12.0
|
||||
with:
|
||||
distribution: 'oracle'
|
||||
java-version: '17'
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -48,3 +48,4 @@ app.*.map.json
|
||||
/android/app/release
|
||||
|
||||
service-account.json
|
||||
.env
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
### 3.5.0 (16-11-2025)
|
||||
**New Features:**
|
||||
- Dark mode support
|
||||
- Cycplus BC2 support (thanks @schneewoehner)
|
||||
- Ignored devices now persist across app restarts - remove them from ignored devices via the menu
|
||||
|
||||
**Fixes:**
|
||||
- resolve issues during app start
|
||||
|
||||
### 3.4.0 (08-11-2025)
|
||||
**New Features:**
|
||||
- Support for Shimano Di2
|
||||
|
||||
21
README.md
21
README.md
@@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
|
||||
## Downloads
|
||||
Check the compatibility matrix below!
|
||||
Best follow our landing page and the "Get Started" button: [swiftcontrol.app](https://swiftcontrol.app/) to understand on which platform you want to run SwiftControl.
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
|
||||
|
||||
@@ -59,19 +59,8 @@ Support for other devices can be added; check the issues tab here on GitHub.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
Follow this compatibility matrix. It all depends on where you want to run your trainer app (e.g. MyWhoosh on):
|
||||
|
||||
| Run Trainer app (MyWhoosh, ...) on: | Possible | Link | Information |
|
||||
|-------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Android | ✅ | <a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a> | |
|
||||
| iPad | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | You will need to use SwiftControl as a "remote" to control the trainer app on your iPad. Typically, you would use an iPhone or an Android phone for that. |
|
||||
| Windows | ✅ | <a href="https://apps.microsoft.com/detail/9NP42GS03Z26"><img width="270" alt="Microsoft Store" src="https://github.com/user-attachments/assets/7a8a3cd6-ec26-4678-a850-732eedd27c48" /></a> | - Windows may flag the app as virus. It likely does so because the app controls the mouse and keyboard.<br>- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).<br>- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70). |
|
||||
| macOS | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a> | |
|
||||
| iPhone | (✅) | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you could use the Link method on another device to control MyWhoosh (and only MyWhoosh) on an iPhone. |
|
||||
| Apple TV | (✅*) | | *only MyWhoosh using the Link method is supported - but you cannot also use MyWhoosh Link at the same time |
|
||||
|
||||
|
||||
For testing purposes, you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/), but this is just a tech demo - you won't be able to control other apps.
|
||||
Follow the "Get Started" button over at [swiftcontrol.app](https://swiftcontrol.app) to understand on which platform you want to run SwiftControl.
|
||||
You can even try it out in your [Browser](https://jonasbark.github.io/swiftcontrol/), if it supports Bluetooth connections. No controlling possible, though.
|
||||
|
||||
## Troubleshooting
|
||||
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
@@ -83,9 +72,9 @@ The app connects to your Controller devices (such as Zwift ones) automatically.
|
||||
- **iOS**: use SwiftControl as a "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Controller devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- If you want to use MyWhoosh, you can use the Link method to directly connect to MyWhoosh
|
||||
- If you want to use MyWhoosh, you can use the Link method to connect to MyWhoosh directly
|
||||
- For other trainer apps, you need to pair SwiftControl to your iPad / tablet via Bluetooth, and your phone will send the button presses to your iPad / tablet
|
||||
- **macOS** / **Windows** a keyboard or mouse click is used to trigger the action.
|
||||
- **macOS** / **Windows** A keyboard or mouse click is used to trigger the action.
|
||||
- There are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
|
||||
- You can also create your own Keymaps for any other app
|
||||
- You can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.3.0
|
||||
3.4.0
|
||||
@@ -10,6 +10,7 @@ import android.graphics.Rect
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
@@ -69,7 +70,7 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!Observable.ignoreHidDevices) {
|
||||
if (!Observable.ignoreHidDevices && isBleRemote(event)) {
|
||||
// Handle media and volume keys from HID devices here
|
||||
Log.d(
|
||||
"AccessibilityService",
|
||||
@@ -87,6 +88,15 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBleRemote(event: KeyEvent): Boolean {
|
||||
val dev = InputDevice.getDevice(event.deviceId) ?: return false
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
dev.isExternal
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
|
||||
val gestureBuilder = GestureDescription.Builder()
|
||||
val path = Path()
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -19,7 +19,6 @@ import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../utils/keymap/apps/my_whoosh.dart';
|
||||
import 'devices/base_device.dart';
|
||||
import 'devices/link/link_device.dart';
|
||||
import 'devices/zwift/constants.dart';
|
||||
import 'messages/notification.dart';
|
||||
|
||||
@@ -40,6 +39,7 @@ class Connection {
|
||||
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
|
||||
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
|
||||
Stream<BaseNotification> get actionStream => _actionStreams.stream;
|
||||
List<({DateTime date, String entry})> lastLogEntries = [];
|
||||
|
||||
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
|
||||
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
|
||||
@@ -52,9 +52,12 @@ class Connection {
|
||||
|
||||
Timer? _gamePadSearchTimer;
|
||||
|
||||
final _dontAllowReconnectDevices = <String>{};
|
||||
|
||||
void initialize() {
|
||||
actionStream.listen((log) {
|
||||
lastLogEntries.add((date: DateTime.now(), entry: log.toString()));
|
||||
lastLogEntries = lastLogEntries.takeLast(20).toList();
|
||||
});
|
||||
|
||||
isMediaKeyDetectionEnabled.addListener(() {
|
||||
if (!isMediaKeyDetectionEnabled.value) {
|
||||
mediaKeyDetector.setIsPlaying(isPlaying: false);
|
||||
@@ -107,14 +110,26 @@ class Connection {
|
||||
}
|
||||
};
|
||||
|
||||
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) {
|
||||
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) async {
|
||||
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
|
||||
if (device == null) {
|
||||
_actionStreams.add(LogNotification('Device not found: $deviceId'));
|
||||
UniversalBle.disconnect(deviceId);
|
||||
return;
|
||||
} else {
|
||||
device.processCharacteristic(characteristicUuid, value);
|
||||
try {
|
||||
await device.processCharacteristic(characteristicUuid, value);
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
"Error processing characteristic for device ${device.name} and char: $characteristicUuid: $e\n$backtrace",
|
||||
),
|
||||
);
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
print("backtrace: $backtrace");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,50 +191,56 @@ class Connection {
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh && !whooshLink.isStarted.value) {
|
||||
startMyWhooshServer();
|
||||
if (settings.getMyWhooshLinkEnabled() &&
|
||||
settings.getTrainerApp() is MyWhoosh &&
|
||||
!whooshLink.isStarted.value &&
|
||||
whooshLink.isCompatible(settings.getLastTarget()!)) {
|
||||
startMyWhooshServer().catchError((e) {
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
'Error starting MyWhoosh Direct Connect server. Please make sure the "MyWhoosh Link" app is not already running on this device.\n$e',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
_androidNotificationsSetup = true;
|
||||
// start foreground service only when app is in foreground
|
||||
NotificationRequirement.setup().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startMyWhooshServer() {
|
||||
return whooshLink.startServer(
|
||||
onConnected: (socket) {
|
||||
final existing = remoteDevices.firstOrNullWhere(
|
||||
(e) => e is LinkDevice && e.identifier == socket.remoteAddress.address,
|
||||
);
|
||||
if (existing != null) {
|
||||
existing.isConnected = true;
|
||||
signalChange(existing);
|
||||
}
|
||||
},
|
||||
onDisconnected: (socket) {
|
||||
final device = devices.firstOrNullWhere(
|
||||
(device) => device is LinkDevice && device.identifier == socket.remoteAddress.address,
|
||||
);
|
||||
if (device != null) {
|
||||
devices.remove(device);
|
||||
signalChange(device);
|
||||
}
|
||||
},
|
||||
onConnected: (socket) {},
|
||||
onDisconnected: (socket) {},
|
||||
);
|
||||
}
|
||||
|
||||
void addDevices(List<BaseDevice> dev) {
|
||||
final newDevices = dev
|
||||
.where((device) => !devices.contains(device) && !_dontAllowReconnectDevices.contains(device.name))
|
||||
.toList();
|
||||
final ignoredDevices = settings.getIgnoredDevices();
|
||||
final ignoredDeviceIds = ignoredDevices.map((d) => d.id).toSet();
|
||||
final newDevices = dev.where((device) {
|
||||
if (devices.contains(device)) return false;
|
||||
|
||||
// Check if device is in the ignored list
|
||||
if (device is BluetoothDevice) {
|
||||
if (ignoredDeviceIds.contains(device.device.deviceId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
devices.addAll(newDevices);
|
||||
_connectionQueue.addAll(newDevices);
|
||||
|
||||
_handleConnectionQueue();
|
||||
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
_androidNotificationsSetup = true;
|
||||
NotificationRequirement.setup().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConnectionQueue() {
|
||||
@@ -258,7 +279,7 @@ class Connection {
|
||||
device.isConnected = state;
|
||||
_connectionStreams.add(device);
|
||||
if (!device.isConnected) {
|
||||
disconnect(device, forget: true);
|
||||
disconnect(device, forget: false);
|
||||
// try reconnect
|
||||
performScanning();
|
||||
}
|
||||
@@ -332,20 +353,26 @@ class Connection {
|
||||
if (device.isConnected) {
|
||||
await device.disconnect();
|
||||
}
|
||||
if (device is! LinkDevice) {
|
||||
// keep it in the list to allow reconnect
|
||||
devices.remove(device);
|
||||
|
||||
if (device is BluetoothDevice) {
|
||||
if (forget) {
|
||||
_dontAllowReconnectDevices.add(device.name);
|
||||
// Add device to ignored list when forgetting
|
||||
await settings.addIgnoredDevice(device.device.deviceId, device.name);
|
||||
_actionStreams.add(LogNotification('Device ignored: ${device.name}'));
|
||||
}
|
||||
}
|
||||
if (!forget && device is BluetoothDevice) {
|
||||
|
||||
// Clean up subscriptions and scan results for reconnection
|
||||
_lastScanResult.removeWhere((b) => b.deviceId == device.device.deviceId);
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
|
||||
// Remove device from the list
|
||||
devices.remove(device);
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
}
|
||||
|
||||
signalChange(device);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ abstract class BaseDevice {
|
||||
Future<void> connect();
|
||||
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
try {
|
||||
await _handleButtonsClickedInternal(buttonsClicked);
|
||||
} catch (e, st) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification('Error handling button clicks: $e\n$st'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked) async {
|
||||
if (buttonsClicked == null) {
|
||||
// ignore, no changes
|
||||
} else if (buttonsClicked.isEmpty) {
|
||||
|
||||
@@ -56,7 +56,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
null => null,
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') || scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
|
||||
_ => null,
|
||||
@@ -71,7 +71,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SQUARE') => EliteSquare(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') || scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
_ 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.services.contains(ShimanoDi2Constants.SERVICE_UUID.toLowerCase()) => ShimanoDi2(scanResult),
|
||||
@@ -115,10 +115,10 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
|
||||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult.deviceId == other.scanResult.deviceId;
|
||||
|
||||
@override
|
||||
int get hashCode => scanResult.hashCode;
|
||||
int get hashCode => scanResult.deviceId.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -192,7 +192,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
device.name?.screenshot ?? device.runtimeType.toString(),
|
||||
device.name?.screenshot ?? runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
|
||||
@@ -27,43 +27,74 @@ class CycplusBc2 extends BluetoothDevice {
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
// Track last state for index 6 and 7
|
||||
int _lastStateIndex6 = 0x00;
|
||||
int _lastStateIndex7 = 0x00;
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
// Process CYCPLUS BC2 data
|
||||
// The BC2 typically sends button press data as simple byte values
|
||||
// Common patterns for virtual shifters:
|
||||
// - 0x01 or similar for shift up
|
||||
// - 0x02 or similar for shift down
|
||||
// - 0x00 for button release
|
||||
if (bytes.length > 7) {
|
||||
final buttonsToPress = <ControllerButton>[];
|
||||
|
||||
if (bytes.isNotEmpty) {
|
||||
final buttonCode = bytes[0];
|
||||
|
||||
switch (buttonCode) {
|
||||
case 0x01:
|
||||
// Shift up button pressed
|
||||
handleButtonsClicked([CycplusBc2Buttons.shiftUp]);
|
||||
break;
|
||||
case 0x02:
|
||||
// Shift down button pressed
|
||||
handleButtonsClicked([CycplusBc2Buttons.shiftDown]);
|
||||
break;
|
||||
case 0x00:
|
||||
// Button released
|
||||
handleButtonsClicked([]);
|
||||
break;
|
||||
default:
|
||||
// Unknown button code - log for debugging
|
||||
actionStreamInternal.add(
|
||||
LogNotification('CYCPLUS BC2: Unknown button code: 0x${buttonCode.toRadixString(16)}'),
|
||||
);
|
||||
break;
|
||||
// Process index 6 (shift up)
|
||||
final currentByte6 = bytes[6];
|
||||
if (_shouldTriggerShift(currentByte6, _lastStateIndex6)) {
|
||||
buttonsToPress.add(CycplusBc2Buttons.shiftUp);
|
||||
_lastStateIndex6 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte6, (val) => _lastStateIndex6 = val);
|
||||
}
|
||||
|
||||
// Process index 7 (shift down)
|
||||
final currentByte7 = bytes[7];
|
||||
if (_shouldTriggerShift(currentByte7, _lastStateIndex7)) {
|
||||
buttonsToPress.add(CycplusBc2Buttons.shiftDown);
|
||||
_lastStateIndex7 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte7, (val) => _lastStateIndex7 = val);
|
||||
}
|
||||
|
||||
handleButtonsClicked(buttonsToPress);
|
||||
} else {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'CYCPLUS BC2 received unexpected packet: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join()}',
|
||||
),
|
||||
);
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
// Check if we should trigger a shift based on current and last state
|
||||
bool _shouldTriggerShift(int currentByte, int lastByte) {
|
||||
const pressedValues = {0x01, 0x02, 0x03};
|
||||
|
||||
// State change from one pressed value to another different pressed value
|
||||
// This is the ONLY time we trigger a shift
|
||||
if (pressedValues.contains(currentByte) && pressedValues.contains(lastByte) && currentByte != lastByte) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update state tracking
|
||||
void _updateState(int currentByte, void Function(int) setState) {
|
||||
const pressedValues = {0x01, 0x02, 0x03};
|
||||
const releaseValue = 0x00;
|
||||
|
||||
// Button released: current is 0x00 and last was pressed
|
||||
if (currentByte == releaseValue) {
|
||||
setState(releaseValue);
|
||||
}
|
||||
// Lock the new pressed state
|
||||
else if (pressedValues.contains(currentByte)) {
|
||||
setState(currentByte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CycplusBc2Constants {
|
||||
|
||||
@@ -38,13 +38,22 @@ class WhooshLink {
|
||||
required void Function(Socket socket) onConnected,
|
||||
required void Function(Socket socket) onDisconnected,
|
||||
}) async {
|
||||
// Create and bind server socket
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
21587,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
try {
|
||||
// Create and bind server socket
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
21587,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to start server: $e');
|
||||
}
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
rethrow;
|
||||
}
|
||||
isStarted.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Server started on port ${_server!.port}');
|
||||
@@ -143,9 +152,11 @@ class WhooshLink {
|
||||
}
|
||||
|
||||
bool isCompatible(Target target) {
|
||||
return switch (target) {
|
||||
Target.thisDevice => Platform.isAndroid || Platform.isWindows,
|
||||
_ => true,
|
||||
};
|
||||
return kIsWeb
|
||||
? false
|
||||
: switch (target) {
|
||||
Target.thisDevice => Platform.isAndroid || Platform.isWindows,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class LinkDevice extends BaseDevice {
|
||||
String identifier;
|
||||
|
||||
LinkDevice(this.identifier) : super('MyWhoosh Direct Connect', availableButtons: []);
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
isConnected = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
super.disconnect();
|
||||
whooshLink.stopServer();
|
||||
isConnected = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final myWhooshExplanation = actionHandler is RemoteActions
|
||||
? 'MyWhoosh Direct Connect allows you to do some additional features such as Emotes and turn directions.'
|
||||
: 'MyWhoosh Direct Connect is optional, but allows you to do some additional features such as Emotes and turn directions.';
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: settings.getMyWhooshLinkEnabled(),
|
||||
onChanged: (value) {
|
||||
settings.setMyWhooshLinkEnabled(value);
|
||||
if (!value) {
|
||||
disconnect();
|
||||
connection.disconnect(this, forget: true);
|
||||
} else if (value) {
|
||||
connection.startMyWhooshServer();
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
title: Text('Enable MyWhoosh Direct Connect'),
|
||||
subtitle: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (!settings.getMyWhooshLinkEnabled())
|
||||
Expanded(
|
||||
child: Text(
|
||||
myWhooshExplanation,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Expanded(
|
||||
child: Text(
|
||||
isConnected ? "Connected" : "Connecting to MyWhoosh...\n$myWhooshExplanation",
|
||||
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (!isConnected) SmallProgressIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
launchUrlString('https://www.youtube.com/watch?v=p8sgQhuufeI');
|
||||
},
|
||||
icon: Icon(Icons.help_outline),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
@@ -25,10 +24,6 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
|
||||
if (actionHandler.supportedApp is! CustomApp) {
|
||||
actionStreamInternal.add(LogNotification('Use a custom keymap to support ${scanResult.name}'));
|
||||
}
|
||||
}
|
||||
|
||||
final _lastButtons = <int, int>{};
|
||||
@@ -82,12 +77,18 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
Text(
|
||||
'Make sure to set your Di2 buttons to D-Fly channels in the Shimano E-TUBE app.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
if (actionHandler.supportedApp is! CustomApp)
|
||||
Text(
|
||||
'Use a custom keymap to support ${scanResult.name}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
@@ -156,7 +158,8 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
@override
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
settings.getVibrationEnabled() &&
|
||||
(this is ZwiftPlay || this is ZwiftRide)) {
|
||||
await _vibrate();
|
||||
}
|
||||
return super.performClick(buttonsClicked);
|
||||
|
||||
@@ -24,8 +24,8 @@ const screenshotMode = false;
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
runApp(const SwiftPlayApp());
|
||||
final error = await settings.init();
|
||||
runApp(SwiftPlayApp(error: error));
|
||||
}
|
||||
|
||||
enum ConnectionType {
|
||||
@@ -60,7 +60,8 @@ Future<void> initializeActions(ConnectionType connectionType) async {
|
||||
}
|
||||
|
||||
class SwiftPlayApp extends StatelessWidget {
|
||||
const SwiftPlayApp({super.key});
|
||||
final String? error;
|
||||
const SwiftPlayApp({super.key, this.error});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -70,8 +71,10 @@ class SwiftPlayApp extends StatelessWidget {
|
||||
title: 'SwiftControl',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: ThemeMode.light,
|
||||
home: const RequirementsPage(),
|
||||
themeMode: ThemeMode.system,
|
||||
home: error != null
|
||||
? Text('There was an error starting the App. Please contact support:\n$error')
|
||||
: const RequirementsPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:swift_control/bluetooth/devices/link/link_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/keymap/manager.dart';
|
||||
import 'package:swift_control/utils/requirements/zwift.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/widgets/apps/mywhoosh_link_tile.dart';
|
||||
import 'package:swift_control/widgets/apps/zwift_tile.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/logviewer.dart';
|
||||
@@ -203,7 +204,9 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
appBar: AppBar(
|
||||
title: AppTitle(),
|
||||
actions: buildMenuButtons(),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
backgroundColor: Theme.brightnessOf(context) == Brightness.light
|
||||
? Theme.of(context).colorScheme.inversePrimary
|
||||
: null,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -334,7 +337,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
|
||||
if (connection.remoteDevices.isNotEmpty ||
|
||||
actionHandler is RemoteActions ||
|
||||
whooshLink.isCompatible(settings.getLastTarget()!) ||
|
||||
whooshLink.isCompatible(settings.getLastTarget() ?? Target.thisDevice) ||
|
||||
actionHandler.supportedApp?.supportsZwiftEmulation == true)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
@@ -360,18 +363,20 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
|
||||
if (settings.getTrainerApp() is MyWhoosh &&
|
||||
whooshLink.isCompatible(settings.getLastTarget()!))
|
||||
LinkDevice('').showInformation(context),
|
||||
MyWhooshLinkTile(),
|
||||
if (settings.getTrainerApp()?.supportsZwiftEmulation == true)
|
||||
ZwiftRequirement().build(context, () {
|
||||
setState(() {});
|
||||
})!,
|
||||
ZwiftTile(
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
|
||||
if (actionHandler is RemoteActions)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected'}',
|
||||
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected (optional)'}',
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (_) => [
|
||||
@@ -392,125 +397,129 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
'Customize ${settings.getTrainerApp()?.name} on ${settings.getLastTarget()?.title}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: canVibrate ? 0 : 12,
|
||||
if (!kIsWeb) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
'Customize ${settings.getTrainerApp()?.name} on ${settings.getLastTarget()?.title}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownMenu<SupportedApp?>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries: [
|
||||
..._getAllApps().map(
|
||||
(app) => DropdownMenuEntry<SupportedApp>(
|
||||
value: app,
|
||||
label: app.name,
|
||||
labelWidget: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(app.name),
|
||||
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: canVibrate ? 0 : 12,
|
||||
),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownMenu<SupportedApp?>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries: [
|
||||
..._getAllApps().map(
|
||||
(app) => DropdownMenuEntry<SupportedApp>(
|
||||
value: app,
|
||||
label: app.name,
|
||||
labelWidget: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(app.name),
|
||||
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
label: 'Create new keymap',
|
||||
labelWidget: Text('Create new keymap'),
|
||||
leadingIcon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
label: Text('Select Keymap'),
|
||||
onSelected: (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);
|
||||
actionHandler.init(customApp);
|
||||
await settings.setKeyMap(customApp);
|
||||
controller.text = profileName;
|
||||
DropdownMenuEntry(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
label: 'Create new keymap',
|
||||
labelWidget: Text('Create new keymap'),
|
||||
leadingIcon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
label: Text('Select Keymap'),
|
||||
onSelected: (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);
|
||||
actionHandler.init(customApp);
|
||||
await settings.setKeyMap(customApp);
|
||||
controller.text = profileName;
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
await settings.setKeyMap(app);
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
await settings.setKeyMap(app);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
KeymapManager().getManageProfileDialog(
|
||||
context,
|
||||
actionHandler.supportedApp is CustomApp ? actionHandler.supportedApp?.name : null,
|
||||
onDone: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
KeymapManager().getManageProfileDialog(
|
||||
context,
|
||||
actionHandler.supportedApp is CustomApp
|
||||
? actionHandler.supportedApp?.name
|
||||
: null,
|
||||
onDone: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (actionHandler.supportedApp is! CustomApp)
|
||||
Text(
|
||||
'Customize the keymap if you experience any issues (e.g. wrong keyboard output, or misaligned touch placements)',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
if (actionHandler.supportedApp != null && connection.controllerDevices.isNotEmpty)
|
||||
KeymapExplanation(
|
||||
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
|
||||
keymap: actionHandler.supportedApp!.keymap,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
settings.setKeyMap(actionHandler.supportedApp!);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (canVibrate) ...[
|
||||
SwitchListTile(
|
||||
title: Text('Enable vibration feedback when shifting gears'),
|
||||
value: settings.getVibrationEnabled(),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) async {
|
||||
await settings.setVibrationEnabled(value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (actionHandler.supportedApp is! CustomApp)
|
||||
Text(
|
||||
'Customize the keymap if you experience any issues (e.g. wrong keyboard output, or misaligned touch placements)',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
if (actionHandler.supportedApp != null && connection.controllerDevices.isNotEmpty)
|
||||
KeymapExplanation(
|
||||
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
|
||||
keymap: actionHandler.supportedApp!.keymap,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
settings.setKeyMap(actionHandler.supportedApp!);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (canVibrate) ...[
|
||||
SwitchListTile(
|
||||
title: Text('Enable vibration feedback when shifting gears'),
|
||||
value: settings.getVibrationEnabled(),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) async {
|
||||
await settings.setVibrationEnabled(value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
|
||||
@@ -54,7 +54,9 @@ class _ChangelogPageState extends State<MarkdownPage> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.assetPath.replaceAll('.md', '').toLowerCase().capitalize),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
backgroundColor: Theme.brightnessOf(context) == Brightness.light
|
||||
? Theme.of(context).colorScheme.inversePrimary
|
||||
: null,
|
||||
),
|
||||
body: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
@@ -68,7 +70,10 @@ class _ChangelogPageState extends State<MarkdownPage> {
|
||||
child: MarkdownWidget(
|
||||
markdown: _markdown!,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(fontSize: 14.0, color: Colors.black87),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
),
|
||||
onLinkTap: (title, url) {
|
||||
launchUrlString(url);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
@@ -31,16 +32,14 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
|
||||
// call after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
settings.init().then((_) {
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
// add more delay due to CBManagerStateUnknown
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
_reloadRequirements();
|
||||
});
|
||||
} else {
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
// add more delay due to CBManagerStateUnknown
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
_reloadRequirements();
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_reloadRequirements();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,8 +61,10 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AppTitle(),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
actions: buildMenuButtons(),
|
||||
backgroundColor: Theme.brightnessOf(context) == Brightness.light
|
||||
? Theme.of(context).colorScheme.inversePrimary
|
||||
: null,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
@@ -106,6 +107,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
: Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Stepper(
|
||||
key: ObjectKey(_requirements.length),
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
currentStep: _currentStep,
|
||||
connectorColor: WidgetStateProperty.resolveWith<Color>(
|
||||
@@ -170,16 +172,28 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
}
|
||||
|
||||
void _callRequirement(PlatformRequirement req, BuildContext context, VoidCallback onUpdate) {
|
||||
req.call(context, onUpdate).then((_) {
|
||||
_reloadRequirements();
|
||||
});
|
||||
req
|
||||
.call(context, onUpdate)
|
||||
.then((_) {
|
||||
return _reloadRequirements();
|
||||
})
|
||||
.catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error handling requirement "${req.name}": $e'),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _reloadRequirements() {
|
||||
getRequirements(
|
||||
settings.getLastTarget()?.connectionType ?? ConnectionType.unknown,
|
||||
).then((req) {
|
||||
void _reloadRequirements() async {
|
||||
try {
|
||||
final req = await getRequirements(
|
||||
settings.getLastTarget()?.connectionType ?? ConnectionType.unknown,
|
||||
);
|
||||
_requirements = req;
|
||||
_currentStep = _currentStep >= _requirements.length ? 0 : _currentStep;
|
||||
setState(() {});
|
||||
final unresolvedIndex = req.indexWhere((req) => !req.status);
|
||||
if (unresolvedIndex != -1) {
|
||||
_currentStep = unresolvedIndex;
|
||||
@@ -199,9 +213,16 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
);
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
connection.signalNotification(LogNotification('Error loading requirements: $e'));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error loading requirements: $e'),
|
||||
),
|
||||
);
|
||||
_currentStep = 0;
|
||||
_requirements = [ErrorRequirement('Error loading requirements: $e')];
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/widgets/button_widget.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/testbed.dart';
|
||||
@@ -396,7 +396,8 @@ class KeypairExplanation extends StatelessWidget {
|
||||
else
|
||||
Icon(keyPair.icon),
|
||||
if (keyPair.inGameAction != null &&
|
||||
((settings.getTrainerApp() is MyWhoosh && settings.getMyWhooshLinkEnabled()) ||
|
||||
((whooshLink.isCompatible(settings.getLastTarget() ?? Target.thisDevice) &&
|
||||
settings.getMyWhooshLinkEnabled()) ||
|
||||
(settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled())))
|
||||
_KeyWidget(
|
||||
label: [
|
||||
|
||||
@@ -30,6 +30,9 @@ abstract final class AppTheme {
|
||||
FlexThemeData.dark(
|
||||
// Using FlexColorScheme built-in FlexScheme enum based colors.
|
||||
scheme: FlexScheme.redM3,
|
||||
primary: Color(0xFF0E74B7),
|
||||
primaryContainer: Color(0x7C0E9297),
|
||||
onPrimaryContainer: Colors.white,
|
||||
// Component theme configurations for dark mode.
|
||||
subThemesData: const FlexSubThemesData(
|
||||
interactionEffects: true,
|
||||
@@ -45,9 +48,11 @@ abstract final class AppTheme {
|
||||
visualDensity: FlexColorScheme.comfortablePlatformDensity,
|
||||
cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true),
|
||||
).copyWith(
|
||||
scaffoldBackgroundColor: Color(0xff0b1623),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Color(0xFF0E74B7),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: Colors.white24,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,8 +99,11 @@ abstract class BaseActions {
|
||||
class StubActions extends BaseActions {
|
||||
StubActions({super.supportedModes = const []});
|
||||
|
||||
final List<ControllerButton> performedActions = [];
|
||||
|
||||
@override
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
performedActions.add(action);
|
||||
return Future.value(action.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,14 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
notificationDetails: AndroidNotificationDetails(
|
||||
channelGroupId,
|
||||
'Keep Alive',
|
||||
actions: [AndroidNotificationAction('Exit', 'Exit', cancelNotification: true, showsUserInterface: false)],
|
||||
actions: [
|
||||
AndroidNotificationAction(
|
||||
'Disconnect Devices',
|
||||
'Disconnect Devices',
|
||||
cancelNotification: true,
|
||||
showsUserInterface: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
@@ -83,6 +84,20 @@ class UnsupportedPlatform extends PlatformRequirement {
|
||||
Future<void> getStatus() async {}
|
||||
}
|
||||
|
||||
class ErrorRequirement extends PlatformRequirement {
|
||||
ErrorRequirement(super.name) {
|
||||
status = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {}
|
||||
}
|
||||
|
||||
typedef BoolFunction = bool Function();
|
||||
|
||||
enum Target {
|
||||
@@ -90,6 +105,10 @@ enum Target {
|
||||
title: 'This Device',
|
||||
icon: Icons.devices,
|
||||
),
|
||||
otherDevice(
|
||||
title: 'Other Device',
|
||||
icon: Icons.settings_remote_outlined,
|
||||
),
|
||||
iOS(
|
||||
title: 'iPhone / iPad / Apple TV',
|
||||
icon: Icons.settings_remote_outlined,
|
||||
@@ -136,13 +155,15 @@ enum Target {
|
||||
'Due to platform restrictions only controlling ${app?.name ?? 'the Trainer app'} on other devices is supported.',
|
||||
Target.thisDevice => 'Run ${app?.name ?? 'the Trainer app'} on this device.',
|
||||
Target.iOS =>
|
||||
'Run ${app?.name ?? 'the Trainer app'} on your Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
'Run ${app?.name ?? 'the Trainer app'} on an Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
Target.android =>
|
||||
'Run ${app?.name ?? 'the Trainer app'} on your Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
'Run ${app?.name ?? 'the Trainer app'} on an Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
Target.macOS =>
|
||||
'Run ${app?.name ?? 'the Trainer app'} on your Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
'Run ${app?.name ?? 'the Trainer app'} on a Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
Target.windows =>
|
||||
'Run ${app?.name ?? 'the Trainer app'} on your Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
'Run ${app?.name ?? 'the Trainer app'} on a Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
Target.otherDevice =>
|
||||
'Run ${app?.name ?? 'the Trainer app'} on another device and control it remotely from this device.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,9 +179,9 @@ enum Target {
|
||||
"Select 'This device' unless you want to control another macOS device. Are you sure?",
|
||||
Target.windows when Platform.isWindows =>
|
||||
"Select 'This device' unless you want to control another Windows device. Are you sure?",
|
||||
Target.android => "Download and use SwiftControl on that Android device.",
|
||||
Target.macOS => "Download and use SwiftControl on that macOS device.",
|
||||
Target.windows => "Download and use SwiftControl on that Windows device.",
|
||||
Target.android => "We highly recommended to download and use SwiftControl on that Android device.",
|
||||
Target.macOS => "We highly recommended to download and use SwiftControl on that macOS device.",
|
||||
Target.windows => "We highly recommended to download and use SwiftControl on that Windows device.",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -199,106 +220,167 @@ class TargetRequirement extends PlatformRequirement {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Select Trainer App', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
DropdownMenu<SupportedApp>(
|
||||
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
|
||||
return DropdownMenuEntry(
|
||||
value: app,
|
||||
label: app.name,
|
||||
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(app.name),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: DropdownMenu<SupportedApp>(
|
||||
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
|
||||
return DropdownMenuEntry(
|
||||
value: app,
|
||||
label: app.name,
|
||||
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(app.name),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(Icons.warning_amber),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}).toList(),
|
||||
hintText: 'Select Trainer app',
|
||||
initialSelection: settings.getTrainerApp(),
|
||||
onSelected: (selectedApp) async {
|
||||
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
|
||||
whooshLink.stopServer();
|
||||
}
|
||||
settings.setTrainerApp(selectedApp!);
|
||||
if (actionHandler.supportedApp == null ||
|
||||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
|
||||
actionHandler.init(selectedApp);
|
||||
settings.setKeyMap(selectedApp);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
Icon(Icons.warning_amber),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}).toList(),
|
||||
hintText: 'Select Trainer app',
|
||||
initialSelection: settings.getTrainerApp(),
|
||||
onSelected: (selectedApp) async {
|
||||
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
|
||||
whooshLink.stopServer();
|
||||
}
|
||||
settings.setTrainerApp(selectedApp!);
|
||||
if (settings.getLastTarget() == null && Target.thisDevice.isCompatible) {
|
||||
await settings.setLastTarget(Target.thisDevice);
|
||||
}
|
||||
if (actionHandler.supportedApp == null ||
|
||||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
|
||||
actionHandler.init(selectedApp);
|
||||
settings.setKeyMap(selectedApp);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Select Target where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
DropdownMenu<Target>(
|
||||
dropdownMenuEntries: Target.values.map((target) {
|
||||
return DropdownMenuEntry(
|
||||
value: target,
|
||||
label: target.title,
|
||||
enabled: target.isCompatible,
|
||||
trailingIcon: Icon(target.icon),
|
||||
labelWidget: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (target.isBeta) BetaPill(),
|
||||
],
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: DropdownMenu<Target>(
|
||||
dropdownMenuEntries: [Target.thisDevice, Target.otherDevice].map((target) {
|
||||
return DropdownMenuEntry(
|
||||
value: target,
|
||||
label: target.title,
|
||||
leadingIcon: Icon(target.icon),
|
||||
labelWidget: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(target.title),
|
||||
Text(
|
||||
target.getDescription(settings.getTrainerApp()),
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
hintText: 'Select Target device',
|
||||
initialSelection: settings.getLastTarget() != Target.thisDevice ? Target.otherDevice : Target.thisDevice,
|
||||
enabled: settings.getTrainerApp() != null,
|
||||
onSelected: (target) async {
|
||||
if (target != null) {
|
||||
await settings.setLastTarget(target);
|
||||
if (target.warning != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(target.warning!),
|
||||
duration: Duration(seconds: 10),
|
||||
),
|
||||
Text(
|
||||
target.getDescription(settings.getTrainerApp()),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
if (target == Target.thisDevice)
|
||||
Container(
|
||||
margin: EdgeInsets.only(top: 12),
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dividerColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
if (settings.getLastTarget() != null && settings.getLastTarget() != Target.thisDevice) ...[
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Select the other device where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: DropdownMenu<Target>(
|
||||
dropdownMenuEntries: Target.values
|
||||
.whereNot((e) => [Target.thisDevice, Target.otherDevice].contains(e))
|
||||
.map((target) {
|
||||
return DropdownMenuEntry(
|
||||
value: target,
|
||||
label: target.title,
|
||||
enabled: target.isCompatible,
|
||||
leadingIcon: Icon(target.icon),
|
||||
labelWidget: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
target.title,
|
||||
style: TextStyle(
|
||||
fontWeight: target == Target.thisDevice && target.isCompatible
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (target.isBeta) BetaPill(),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
target.getDescription(settings.getTrainerApp()),
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
hintText: 'Select Target device',
|
||||
initialSelection: settings.getLastTarget(),
|
||||
onSelected: (target) async {
|
||||
if (target != null) {
|
||||
await settings.setLastTarget(target);
|
||||
initializeActions(target.connectionType);
|
||||
if (target.warning != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(target.warning!),
|
||||
duration: Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(),
|
||||
hintText: 'Select Target device',
|
||||
initialSelection: settings.getLastTarget(),
|
||||
enabled: settings.getTrainerApp() != null,
|
||||
onSelected: (target) async {
|
||||
if (target != null) {
|
||||
await settings.setLastTarget(target);
|
||||
initializeActions(target.connectionType);
|
||||
if (target.warning != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(target.warning!),
|
||||
duration: Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: settings.getTrainerApp() != null && settings.getLastTarget() != null
|
||||
? () {
|
||||
|
||||
@@ -326,7 +326,11 @@ class _PairWidgetState extends State<_PairWidget> {
|
||||
Text('Connect via MyWhoosh Direct Connect'),
|
||||
Text(
|
||||
'Most reliable way to control MyWhoosh.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.normal),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -352,7 +356,11 @@ class _PairWidgetState extends State<_PairWidget> {
|
||||
Text('Connect to ${settings.getTrainerApp()?.name} as controller'),
|
||||
Text(
|
||||
'Most reliable way to control ${settings.getTrainerApp()?.name}.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.normal),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -378,7 +386,11 @@ class _PairWidgetState extends State<_PairWidget> {
|
||||
),
|
||||
Text(
|
||||
'Pairing allows full customizability,\nbut may not work on all devices.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.normal),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider_windows/path_provider_windows.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:shared_preferences_windows/shared_preferences_windows.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
@@ -13,22 +17,40 @@ import '../keymap/apps/custom_app.dart';
|
||||
class Settings {
|
||||
late final SharedPreferences prefs;
|
||||
|
||||
Future<void> init() async {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
|
||||
|
||||
if (actionHandler is DesktopActions) {
|
||||
// Must add this line.
|
||||
await windowManager.ensureInitialized();
|
||||
}
|
||||
|
||||
Future<String?> init({bool retried = false}) async {
|
||||
try {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
|
||||
|
||||
if (actionHandler is DesktopActions) {
|
||||
// Must add this line.
|
||||
await windowManager.ensureInitialized();
|
||||
}
|
||||
|
||||
final app = getKeyMap();
|
||||
actionHandler.init(app);
|
||||
} catch (e) {
|
||||
// couldn't decode, reset
|
||||
await prefs.clear();
|
||||
rethrow;
|
||||
return null;
|
||||
} catch (e, s) {
|
||||
if (!retried) {
|
||||
if (Platform.isWindows) {
|
||||
// delete settings file
|
||||
final fs = SharedPreferencesWindows.instance.fs;
|
||||
|
||||
final pathProvider = PathProviderWindows();
|
||||
final String? directory = await pathProvider.getApplicationSupportPath();
|
||||
if (directory == null) {
|
||||
return null;
|
||||
}
|
||||
final String fileLocation = path.join(directory, 'shared_preferences.json');
|
||||
final file = fs.file(fileLocation);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
return init(retried: true);
|
||||
} else {
|
||||
return '$e\n$s';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +166,7 @@ class Settings {
|
||||
|
||||
Future<void> setLastTarget(Target target) async {
|
||||
await prefs.setString('last_target', target.name);
|
||||
initializeActions(target.connectionType);
|
||||
}
|
||||
|
||||
Future<void> setLastSeenVersion(String version) async {
|
||||
@@ -181,4 +204,48 @@ class Settings {
|
||||
Future<void> setMiuiWarningDismissed(bool dismissed) async {
|
||||
await prefs.setBool('miui_warning_dismissed', dismissed);
|
||||
}
|
||||
|
||||
List<String> _getIgnoredDeviceIds() {
|
||||
return prefs.getStringList('ignored_device_ids') ?? [];
|
||||
}
|
||||
|
||||
List<String> _getIgnoredDeviceNames() {
|
||||
return prefs.getStringList('ignored_device_names') ?? [];
|
||||
}
|
||||
|
||||
Future<void> addIgnoredDevice(String deviceId, String deviceName) async {
|
||||
final ids = _getIgnoredDeviceIds();
|
||||
final names = _getIgnoredDeviceNames();
|
||||
|
||||
if (!ids.contains(deviceId)) {
|
||||
ids.add(deviceId);
|
||||
names.add(deviceName);
|
||||
await prefs.setStringList('ignored_device_ids', ids);
|
||||
await prefs.setStringList('ignored_device_names', names);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeIgnoredDevice(String deviceId) async {
|
||||
final ids = _getIgnoredDeviceIds();
|
||||
final names = _getIgnoredDeviceNames();
|
||||
|
||||
final index = ids.indexOf(deviceId);
|
||||
if (index != -1) {
|
||||
ids.removeAt(index);
|
||||
names.removeAt(index);
|
||||
await prefs.setStringList('ignored_device_ids', ids);
|
||||
await prefs.setStringList('ignored_device_names', names);
|
||||
}
|
||||
}
|
||||
|
||||
List<({String id, String name})> getIgnoredDevices() {
|
||||
final ids = _getIgnoredDeviceIds();
|
||||
final names = _getIgnoredDeviceNames();
|
||||
|
||||
final result = <({String id, String name})>[];
|
||||
for (int i = 0; i < ids.length && i < names.length; i++) {
|
||||
result.add((id: ids[i], name: names[i]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
93
lib/widgets/apps/mywhoosh_link_tile.dart
Normal file
93
lib/widgets/apps/mywhoosh_link_tile.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../small_progress_indicator.dart';
|
||||
|
||||
class MyWhooshLinkTile extends StatefulWidget {
|
||||
const MyWhooshLinkTile({super.key});
|
||||
|
||||
@override
|
||||
State<MyWhooshLinkTile> createState() => _MywhooshLinkTileState();
|
||||
}
|
||||
|
||||
class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isStarted,
|
||||
builder: (context, isStarted, _) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final myWhooshExplanation = actionHandler is RemoteActions
|
||||
? 'MyWhoosh Direct Connect allows you to do some additional features such as Emotes and turn directions.'
|
||||
: 'MyWhoosh Direct Connect is optional, but allows you to do some additional features such as Emotes and turn directions.';
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: isStarted,
|
||||
onChanged: (value) {
|
||||
settings.setMyWhooshLinkEnabled(value);
|
||||
if (!value) {
|
||||
whooshLink.stopServer();
|
||||
} else if (value) {
|
||||
connection.startMyWhooshServer().catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Error starting MyWhoosh Direct Connect server. Please make sure the "MyWhoosh Link" app is not already running on this device.',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
title: Text('Enable MyWhoosh Direct Connect'),
|
||||
subtitle: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (!isStarted)
|
||||
Expanded(
|
||||
child: Text(
|
||||
myWhooshExplanation,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Expanded(
|
||||
child: Text(
|
||||
isConnected ? "Connected" : "Connecting to MyWhoosh...\n$myWhooshExplanation",
|
||||
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (isStarted) SmallProgressIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
launchUrlString('https://www.youtube.com/watch?v=p8sgQhuufeI');
|
||||
},
|
||||
icon: Icon(Icons.help_outline),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,22 @@
|
||||
import 'package:flutter/material.dart' hide ConnectionState;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/rouvy.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
|
||||
class ZwiftRequirement extends PlatformRequirement {
|
||||
ZwiftRequirement()
|
||||
: super(
|
||||
'Pair SwiftControl with Zwift',
|
||||
);
|
||||
class ZwiftTile extends StatefulWidget {
|
||||
final VoidCallback onUpdate;
|
||||
|
||||
const ZwiftTile({super.key, required this.onUpdate});
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
|
||||
State<ZwiftTile> createState() => _ZwiftTileState();
|
||||
}
|
||||
|
||||
class _ZwiftTileState extends State<ZwiftTile> {
|
||||
@override
|
||||
Widget? buildDescription() {
|
||||
return settings.getLastTarget() == null
|
||||
? null
|
||||
: Text(
|
||||
'In Zwift on your ${settings.getLastTarget()?.title} go into the Pairing settings and select SwiftControl from the list of available controllers.',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: zwiftEmulator.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
@@ -39,7 +30,7 @@ class ZwiftRequirement extends PlatformRequirement {
|
||||
if (!value) {
|
||||
zwiftEmulator.stopAdvertising();
|
||||
} else if (value) {
|
||||
zwiftEmulator.startAdvertising(onUpdate);
|
||||
zwiftEmulator.startAdvertising(widget.onUpdate);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
@@ -75,9 +66,4 @@ class ZwiftRequirement extends PlatformRequirement {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
status = zwiftEmulator.isConnected.value || screenshotMode;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,12 @@ class ChangelogDialog extends StatelessWidget {
|
||||
constraints: BoxConstraints(minWidth: 460),
|
||||
child: MarkdownWidget(markdown: latestVersion),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Got it!'))],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Got it!'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
75
lib/widgets/ignored_devices_dialog.dart
Normal file
75
lib/widgets/ignored_devices_dialog.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
|
||||
class IgnoredDevicesDialog extends StatefulWidget {
|
||||
const IgnoredDevicesDialog({super.key});
|
||||
|
||||
@override
|
||||
State<IgnoredDevicesDialog> createState() => _IgnoredDevicesDialogState();
|
||||
}
|
||||
|
||||
class _IgnoredDevicesDialogState extends State<IgnoredDevicesDialog> {
|
||||
List<({String id, String name})> _ignoredDevices = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadIgnoredDevices();
|
||||
}
|
||||
|
||||
void _loadIgnoredDevices() {
|
||||
setState(() {
|
||||
_ignoredDevices = settings.getIgnoredDevices();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _removeDevice(String deviceId) async {
|
||||
await settings.removeIgnoredDevice(deviceId);
|
||||
_loadIgnoredDevices();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Ignored Devices'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: _ignoredDevices.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'No ignored devices.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _ignoredDevices.length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = _ignoredDevices[index];
|
||||
return ListTile(
|
||||
title: Text(device.name),
|
||||
subtitle: Text(
|
||||
device.id,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.delete_outline),
|
||||
tooltip: 'Remove from ignored list',
|
||||
onPressed: () => _removeDevice(device.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
@@ -12,6 +13,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../pages/device.dart';
|
||||
import 'ignored_devices_dialog.dart';
|
||||
|
||||
List<Widget> buildMenuButtons() {
|
||||
return [
|
||||
@@ -91,9 +93,9 @@ List<Widget> buildMenuButtons() {
|
||||
child: Text('Get Support'),
|
||||
onTap: () {
|
||||
final isFromStore = (Platform.isAndroid ? isFromPlayStore == true : Platform.isIOS);
|
||||
final suffix = isFromStore ? '' : 'ler';
|
||||
final suffix = isFromStore ? '' : '-sw';
|
||||
|
||||
String email = Uri.encodeComponent('jonas.t.bark+swiftcontrol$suffix@gmail.com');
|
||||
String email = Uri.encodeComponent('jonas$suffix@swiftcontrol.app');
|
||||
String subject = Uri.encodeComponent("Help requested for SwiftControl v${packageInfoValue?.version}");
|
||||
String body = Uri.encodeComponent("""
|
||||
|
||||
@@ -101,9 +103,11 @@ List<Widget> buildMenuButtons() {
|
||||
---
|
||||
App Version: ${packageInfoValue?.version}${shorebirdPatch?.number != null ? '+${shorebirdPatch!.number}' : ''}
|
||||
Platform: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}
|
||||
Target: ${settings.getLastTarget()?.title}
|
||||
Trainer App: ${settings.getTrainerApp()?.name}
|
||||
Target: ${settings.getLastTarget()?.title ?? '-'}
|
||||
Trainer App: ${settings.getTrainerApp()?.name ?? '-'}
|
||||
Connected Controllers: ${connection.devices.map((e) => e.toString()).join(', ')}
|
||||
Logs:
|
||||
${connection.lastLogEntries.reversed.joinToString(separator: '\n', transform: (e) => '${e.date.toString().split('.').first} - ${e.entry}')}
|
||||
|
||||
Please don't remove this information, it helps me to assist you better.""");
|
||||
Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body");
|
||||
@@ -171,7 +175,15 @@ class MenuButton extends StatelessWidget {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
|
||||
},
|
||||
),
|
||||
|
||||
PopupMenuItem(
|
||||
child: Text('Ignored Devices'),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => IgnoredDevicesDialog(),
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('License'),
|
||||
onTap: () {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: swift_control
|
||||
description: "SwiftControl - Control your virtual riding"
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 3.4.0+37
|
||||
version: 3.5.1+41
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.0
|
||||
|
||||
25
scripts/generate_release_body.sh
Executable file
25
scripts/generate_release_body.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Script to generate GitHub release body with changelog and store links
|
||||
# Usage: ./scripts/generate_release_body.sh
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
RELEASE_NOTES_FILE="$SCRIPT_DIR/RELEASE_NOTES.md"
|
||||
|
||||
if [ ! -f "$RELEASE_NOTES_FILE" ]; then
|
||||
echo "Error: RELEASE_NOTES.md not found at $RELEASE_NOTES_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract the first changelog entry using get_latest_changelog.sh
|
||||
# For GitHub releases, we want to include the version header
|
||||
VERSION_HEADER=$(awk '/^### / {print; exit}' "$SCRIPT_DIR/../CHANGELOG.md")
|
||||
LATEST_CHANGELOG=$("$SCRIPT_DIR/get_latest_changelog.sh")
|
||||
|
||||
# Combine changelog with release notes
|
||||
echo "$VERSION_HEADER"
|
||||
echo "$LATEST_CHANGELOG"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
cat "$RELEASE_NOTES_FILE"
|
||||
@@ -1,106 +1,126 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/bluetooth/devices/cycplus/cycplus_bc2.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
void main() {
|
||||
group('CYCPLUS BC2 Virtual Shifter Tests', () {
|
||||
test('Should recognize shift up button code', () {
|
||||
// Test button code recognition
|
||||
const shiftUpCode = 0x01;
|
||||
const shiftDownCode = 0x02;
|
||||
const releaseCode = 0x00;
|
||||
test('Test state machine with full sequence', () {
|
||||
actionHandler = StubActions();
|
||||
|
||||
final stubActions = actionHandler as StubActions;
|
||||
|
||||
final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name'));
|
||||
|
||||
expect(shiftUpCode, equals(0x01));
|
||||
expect(shiftDownCode, equals(0x02));
|
||||
expect(releaseCode, equals(0x00));
|
||||
// Packet 0: [6]=01 [7]=03 -> No trigger (lock state)
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206010397565E000155'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
// Packet 1: [6]=03 [7]=03 -> Trigger: shiftUp
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206030398565E000158'),
|
||||
);
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
|
||||
stubActions.performedActions.clear();
|
||||
|
||||
// Packet 2: [6]=03 [7]=01 -> Trigger: shiftDown
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206030198575E000157'),
|
||||
);
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftDown);
|
||||
stubActions.performedActions.clear();
|
||||
|
||||
// Packet 3: [6]=03 [7]=03 -> No trigger (lock state)
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206030398585E00015A'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
// Packet 4: [6]=01 [7]=03 -> Trigger: shiftUp
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206010399585E000159'),
|
||||
);
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
|
||||
stubActions.performedActions.clear();
|
||||
});
|
||||
|
||||
test('Should handle button press and release cycle', () {
|
||||
// Test button state transitions
|
||||
final states = [0x01, 0x00, 0x02, 0x00];
|
||||
test('Test release and re-press behavior', () {
|
||||
actionHandler = StubActions();
|
||||
final stubActions = actionHandler as StubActions;
|
||||
final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name'));
|
||||
|
||||
expect(states[0], equals(0x01)); // Shift up pressed
|
||||
expect(states[1], equals(0x00)); // Button released
|
||||
expect(states[2], equals(0x02)); // Shift down pressed
|
||||
expect(states[3], equals(0x00)); // Button released
|
||||
// Press: lock state
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206010300005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
// Release: reset state
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206000000005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
// Press again: lock state (no trigger since we reset)
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206020300005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
// Change to different pressed value: trigger
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206010300005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
|
||||
});
|
||||
|
||||
test('Should validate UART service UUID format', () {
|
||||
// Nordic UART Service UUID
|
||||
const serviceUuid = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
test('Test both buttons can trigger simultaneously', () {
|
||||
actionHandler = StubActions();
|
||||
final stubActions = actionHandler as StubActions;
|
||||
final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name'));
|
||||
|
||||
expect(serviceUuid.length, equals(36));
|
||||
expect(serviceUuid.contains('-'), isTrue);
|
||||
expect(serviceUuid.toLowerCase(), equals(serviceUuid));
|
||||
});
|
||||
|
||||
test('Should validate TX characteristic UUID format', () {
|
||||
// TX Characteristic UUID (device to app)
|
||||
const txCharUuid = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
// Lock both states
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206010100005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
expect(txCharUuid.length, equals(36));
|
||||
expect(txCharUuid.contains('-'), isTrue);
|
||||
expect(txCharUuid.toLowerCase(), equals(txCharUuid));
|
||||
});
|
||||
|
||||
test('Should validate RX characteristic UUID format', () {
|
||||
// RX Characteristic UUID (app to device)
|
||||
const rxCharUuid = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
expect(rxCharUuid.length, equals(36));
|
||||
expect(rxCharUuid.contains('-'), isTrue);
|
||||
expect(rxCharUuid.toLowerCase(), equals(rxCharUuid));
|
||||
});
|
||||
});
|
||||
|
||||
group('CYCPLUS BC2 Button Code Tests', () {
|
||||
test('Should differentiate between shift up and shift down', () {
|
||||
const shiftUpCode = 0x01;
|
||||
const shiftDownCode = 0x02;
|
||||
|
||||
expect(shiftUpCode != shiftDownCode, isTrue);
|
||||
expect(shiftUpCode < shiftDownCode, isTrue);
|
||||
});
|
||||
|
||||
test('Should recognize release code as different from press codes', () {
|
||||
const releaseCode = 0x00;
|
||||
const shiftUpCode = 0x01;
|
||||
const shiftDownCode = 0x02;
|
||||
|
||||
expect(releaseCode != shiftUpCode, isTrue);
|
||||
expect(releaseCode != shiftDownCode, isTrue);
|
||||
expect(releaseCode < shiftUpCode, isTrue);
|
||||
expect(releaseCode < shiftDownCode, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('CYCPLUS BC2 Device Name Recognition Tests', () {
|
||||
test('Should recognize CYCPLUS device name', () {
|
||||
const deviceName1 = 'CYCPLUS BC2';
|
||||
const deviceName2 = 'Cycplus BC2';
|
||||
const deviceName3 = 'CYCPLUS';
|
||||
|
||||
expect(deviceName1.toUpperCase().startsWith('CYCPLUS'), isTrue);
|
||||
expect(deviceName2.toUpperCase().startsWith('CYCPLUS'), isTrue);
|
||||
expect(deviceName3.toUpperCase().startsWith('CYCPLUS'), isTrue);
|
||||
});
|
||||
|
||||
test('Should recognize BC2 in device name', () {
|
||||
const deviceName1 = 'CYCPLUS BC2';
|
||||
const deviceName2 = 'BC2 Shifter';
|
||||
const deviceName3 = 'Virtual BC2';
|
||||
|
||||
expect(deviceName1.toUpperCase().contains('BC2'), isTrue);
|
||||
expect(deviceName2.toUpperCase().contains('BC2'), isTrue);
|
||||
expect(deviceName3.toUpperCase().contains('BC2'), isTrue);
|
||||
});
|
||||
|
||||
test('Should not match non-CYCPLUS devices', () {
|
||||
const deviceName1 = 'Zwift Click';
|
||||
const deviceName2 = 'Elite Sterzo';
|
||||
const deviceName3 = 'Wahoo KICKR';
|
||||
|
||||
expect(deviceName1.toUpperCase().startsWith('CYCPLUS'), isFalse);
|
||||
expect(deviceName2.toUpperCase().startsWith('CYCPLUS'), isFalse);
|
||||
expect(deviceName3.toUpperCase().startsWith('CYCPLUS'), isFalse);
|
||||
// Change both: trigger both
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206020200005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.length, 2);
|
||||
expect(stubActions.performedActions.contains(CycplusBc2Buttons.shiftUp), true);
|
||||
expect(stubActions.performedActions.contains(CycplusBc2Buttons.shiftDown), true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Uint8List _hexToUint8List(String seq) {
|
||||
return Uint8List.fromList(
|
||||
List.generate(
|
||||
seq.length ~/ 2,
|
||||
(i) => int.parse(seq.substring(i * 2, i * 2 + 2), radix: 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Zwift Ride Scanner (Protocol Buffers)</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- thanks to https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/ and https://www.makinolo.com/blog/2023/10/08/connecting-to-zwift-play-controllers/ -->
|
||||
<h1>Zwift Ride Scanner</h1>
|
||||
<button onclick="scanForDevices()">Scan for Devices</button>
|
||||
<div id="status">Status: Disconnected</div>
|
||||
<pre id="log" style="white-space: pre-wrap"></pre>
|
||||
|
||||
<script>
|
||||
const statusDiv = document.getElementById("status");
|
||||
const logDiv = document.getElementById("log");
|
||||
let controlCharacteristic = null;
|
||||
|
||||
const BUTTON_MASKS = {
|
||||
LEFT_BTN: 0x1,
|
||||
UP_BTN: 0x2,
|
||||
RIGHT_BTN: 0x4,
|
||||
DOWN_BTN: 0x8,
|
||||
A_BTN: 0x10,
|
||||
B_BTN: 0x20,
|
||||
Y_BTN: 0x40,
|
||||
Z_BTN: 0x100,
|
||||
SHFT_UP_L_BTN: 0x200,
|
||||
SHFT_DN_L_BTN: 0x400,
|
||||
POWERUP_L_BTN: 0x800,
|
||||
ONOFF_L_BTN: 0x1000,
|
||||
SHFT_UP_R_BTN: 0x2000,
|
||||
SHFT_DN_R_BTN: 0x4000,
|
||||
POWERUP_R_BTN: 0x10000,
|
||||
ONOFF_R_BTN: 0x20000,
|
||||
};
|
||||
|
||||
function log(message) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
logDiv.textContent += `[${timestamp}] ${message}\n`;
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function parseKeyPress(buffer) {
|
||||
let location = null;
|
||||
let analogValue = null;
|
||||
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const tag = buffer[offset];
|
||||
const fieldNum = tag >> 3;
|
||||
const wireType = tag & 0x7;
|
||||
offset++;
|
||||
|
||||
switch (fieldNum) {
|
||||
case 1: // Location
|
||||
if (wireType === 0) {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
while (true) {
|
||||
const byte = buffer[offset++];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if ((byte & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
location = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 2: // AnalogValue
|
||||
if (wireType === 0) {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
while (true) {
|
||||
const byte = buffer[offset++];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
if ((byte & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
// ZigZag decode for sint32
|
||||
analogValue = (value >>> 1) ^ -(value & 1);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Skip unknown fields
|
||||
if (wireType === 0) {
|
||||
while (buffer[offset++] & 0x80);
|
||||
} else if (wireType === 2) {
|
||||
const length = buffer[offset++];
|
||||
offset += length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { location: location, value: analogValue };
|
||||
}
|
||||
|
||||
function parseKeyGroup(buffer) {
|
||||
let groupStatus = {};
|
||||
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const tag = buffer[offset];
|
||||
const fieldNum = tag >> 3;
|
||||
const wireType = tag & 0x7;
|
||||
offset++;
|
||||
|
||||
if (fieldNum === 3 && wireType === 2) {
|
||||
const length = buffer[offset++];
|
||||
const messageBuffer = buffer.slice(
|
||||
offset,
|
||||
offset + length,
|
||||
);
|
||||
let res = parseKeyPress(messageBuffer);
|
||||
groupStatus[res.location] = res.value;
|
||||
offset += length;
|
||||
} else {
|
||||
// Skip unknown fields
|
||||
if (wireType === 0) {
|
||||
while (buffer[offset++] & 0x80);
|
||||
} else if (wireType === 2) {
|
||||
const length = buffer[offset++];
|
||||
offset += length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return groupStatus;
|
||||
}
|
||||
|
||||
function parseButtonState(buttonMap) {
|
||||
const pressedButtons = [];
|
||||
for (const [button, mask] of Object.entries(BUTTON_MASKS)) {
|
||||
if ((buttonMap & mask) === 0) {
|
||||
pressedButtons.push(button);
|
||||
}
|
||||
}
|
||||
return pressedButtons;
|
||||
}
|
||||
|
||||
function parseAnalogMessage(data) {
|
||||
// Each analog group starts with 0x1a
|
||||
if (data[0] !== 0x1a) return null;
|
||||
|
||||
let res = parseKeyGroup(data);
|
||||
return {
|
||||
left: "0" in res ? res["0"] : 0,
|
||||
right: "1" in res ? res["1"] : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function handleMessage(value) {
|
||||
const data = new Uint8Array(value.buffer);
|
||||
const msgType = data[0];
|
||||
|
||||
switch (msgType) {
|
||||
case 0x23: {
|
||||
// Button status
|
||||
const buttonMap =
|
||||
data[2] |
|
||||
(data[3] << 8) |
|
||||
(data[4] << 16) |
|
||||
(data[5] << 24);
|
||||
const pressedButtons = parseButtonState(buttonMap);
|
||||
|
||||
if (pressedButtons.length > 0) {
|
||||
log(
|
||||
`Buttons pressed! ${pressedButtons.join(", ")}`,
|
||||
);
|
||||
}
|
||||
// Find analog values section (after button map)
|
||||
let startIndex = 7; // Skip message type, field number, and button map
|
||||
while (startIndex < data.length) {
|
||||
const analogData = parseAnalogMessage(
|
||||
data.slice(startIndex),
|
||||
);
|
||||
if (!analogData) break;
|
||||
|
||||
log(
|
||||
`Analog left:${analogData.left} right:${analogData.right}`,
|
||||
);
|
||||
startIndex = analogData.nextIndex;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 0x2a: // Initial status
|
||||
log("Initial status received");
|
||||
break;
|
||||
|
||||
case 0x15: // Idle
|
||||
break;
|
||||
|
||||
case 0x19: // Status update
|
||||
break;
|
||||
|
||||
default:
|
||||
log(
|
||||
`Unknown message: ${Array.from(data)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join(" ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanForDevices() {
|
||||
if (!navigator.bluetooth) {
|
||||
statusDiv.textContent =
|
||||
"Status: Web Bluetooth API is not supported";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
statusDiv.textContent = "Status: Scanning...";
|
||||
logDiv.textContent = "";
|
||||
|
||||
const device = await navigator.bluetooth.requestDevice({
|
||||
filters: [
|
||||
{
|
||||
name: "Zwift Ride",
|
||||
},
|
||||
],
|
||||
optionalServices: [
|
||||
"0000180f-0000-1000-8000-00805f9b34fb", // Battery Service
|
||||
"0000180a-0000-1000-8000-00805f9b34fb", // Device Information
|
||||
"0000fc82-0000-1000-8000-00805f9b34fb", // Custom Service
|
||||
],
|
||||
});
|
||||
|
||||
log(`Device name: ${device.name}`);
|
||||
log(`Device ID: ${device.id}`);
|
||||
|
||||
statusDiv.textContent = "Status: Connecting...";
|
||||
const server = await device.gatt.connect();
|
||||
log("Connected to GATT server");
|
||||
|
||||
const service = await server.getPrimaryService(
|
||||
"0000fc82-0000-1000-8000-00805f9b34fb",
|
||||
);
|
||||
log("Found custom service");
|
||||
|
||||
const measurementChar = await service.getCharacteristic(
|
||||
"00000002-19ca-4651-86e5-fa29dcdd09d1",
|
||||
);
|
||||
controlCharacteristic = await service.getCharacteristic(
|
||||
"00000003-19ca-4651-86e5-fa29dcdd09d1",
|
||||
);
|
||||
const responseChar = await service.getCharacteristic(
|
||||
"00000004-19ca-4651-86e5-fa29dcdd09d1",
|
||||
);
|
||||
|
||||
log("Got service characteristics");
|
||||
|
||||
// Initial handshake
|
||||
const handshake = new TextEncoder().encode("RideOn");
|
||||
await controlCharacteristic.writeValue(handshake);
|
||||
log("Sent RideOn handshake");
|
||||
|
||||
// Set up notifications
|
||||
await measurementChar.startNotifications();
|
||||
measurementChar.addEventListener(
|
||||
"characteristicvaluechanged",
|
||||
(event) => {
|
||||
handleMessage(event.target.value);
|
||||
},
|
||||
);
|
||||
|
||||
await responseChar.startNotifications();
|
||||
responseChar.addEventListener(
|
||||
"characteristicvaluechanged",
|
||||
(event) => {
|
||||
const value = new TextDecoder().decode(
|
||||
event.target.value,
|
||||
);
|
||||
log(`Response: ${value}`);
|
||||
},
|
||||
);
|
||||
|
||||
device.addEventListener("gattserverdisconnected", () => {
|
||||
statusDiv.textContent = "Status: Device disconnected";
|
||||
log("Device disconnected");
|
||||
});
|
||||
|
||||
statusDiv.textContent =
|
||||
"Status: Connected and watching for input";
|
||||
} catch (error) {
|
||||
statusDiv.textContent = `Status: Error - ${error}`;
|
||||
log(`Error: ${error.message}`);
|
||||
console.error("Error:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user