Compare commits

..

28 Commits

Author SHA1 Message Date
jonasbark
07ee91c17a Clarify download link for latest version
Updated the download link description for clarity.
2025-09-25 13:46:34 +02:00
Jonas Bark
323a344c3a actions test 2025-09-25 13:42:08 +02:00
Jonas Bark
0172b1cf90 actions test 2025-09-25 13:26:24 +02:00
Jonas Bark
5a5e4066f6 Merge remote-tracking branch 'origin/main' 2025-09-25 12:56:10 +02:00
Jonas Bark
3256f5aa15 actions test 2025-09-25 12:56:02 +02:00
Jonas Bark
476a9a337f actions test 2025-09-25 12:54:22 +02:00
jonasbark
1f1ce58bd9 Update CHANGELOG for version 2.5.0
Added note about voucher for donors
2025-09-25 11:34:40 +02:00
Jonas Bark
bbb3dd3397 increase version 2025-09-25 11:16:49 +02:00
Jonas Bark
d7cee77c8b improve usability 2025-09-25 11:03:33 +02:00
Jonas Bark
e2ac975c75 rename Android package name, revert Zwift Click V2 encryption support, add play store assets 2025-09-24 09:12:21 +02:00
Jonas Bark
5e9352316c offer to get app from Play Store 2025-09-24 08:51:19 +02:00
Jonas Bark
c73adb7c0d version++ 2025-09-24 08:47:44 +02:00
Jonas Bark
c3b41f56d4 Merge remote-tracking branch 'origin/copilot/fix-74' 2025-09-24 08:42:39 +02:00
copilot-swe-agent[bot]
6fe841af58 Enhance disclosure dialog with navigation prevention and Play Store description
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-24 06:28:31 +00:00
copilot-swe-agent[bot]
d97307de6f Add accessibility disclosure dialog with proper consent options
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-24 06:26:05 +00:00
copilot-swe-agent[bot]
826dc2327f Initial plan 2025-09-24 06:20:32 +00:00
Jonas Bark
3466e504e3 implement in app update for Android 2025-09-22 13:41:50 +02:00
Jonas Bark
ebd7f80947 upload app bundle to play store 2025-09-22 13:27:30 +02:00
Jonas Bark
43e827d8f5 build app bundle for play store 2025-09-22 10:11:25 +02:00
Jonas Bark
5d5dc2e152 build app bundle for play store 2025-09-22 09:53:25 +02:00
Jonas Bark
c0d2eaa897 adjust readme to ensure Windows users to not pair their Zwift device with Windows 2025-09-22 09:35:55 +02:00
Jonas Bark
13c70fc445 enable encryption for Zwift Click v2 to potentially fix #68 2025-09-22 09:28:35 +02:00
jonasbark
1e11d28765 Merge pull request #71 from jonasbark/copilot/fix-64
Fix Windows mouse clicks at wrong location due to display scaling
2025-09-17 08:49:53 +02:00
copilot-swe-agent[bot]
7ee9bc43a0 Fix changelog date to 2025-09-17
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-17 06:49:09 +00:00
copilot-swe-agent[bot]
372085ec0e Update version to 2.4.0+1 and add changelog entry
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-17 06:46:52 +00:00
copilot-swe-agent[bot]
e758b35837 Fix Windows mouse click scaling for high DPI displays
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-17 06:32:42 +00:00
copilot-swe-agent[bot]
dee7b86120 Initial plan 2025-09-17 06:28:06 +00:00
Jonas Bark
b3ec7e7a3a funding 2025-09-16 20:08:51 +02:00
34 changed files with 520 additions and 156 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
github: [jonasbark]
custom: ["https://paypal.me/boni"]
custom: ["https://paypal.me/boni", "https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200"]

View File

@@ -82,10 +82,12 @@ jobs:
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
#6 Building APK
- name: Build APK
run: flutter build apk --release
- name: Build Bundle
run: flutter build appbundle --release
- name: Build Web
run: flutter build web --release --base-href "/swiftcontrol/"
@@ -134,6 +136,8 @@ jobs:
uses: ncipollo/release-action@v1
with:
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
allowUpdates: true
body: "You can also download the Android version from the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
@@ -146,6 +150,16 @@ jobs:
- name: Web Deploy
uses: actions/deploy-pages@v4
- name: Upload to Play Store
# only upload when env.VERSION does not end with 1337, which is our indicator for beta releases
if: "!endsWith(env.VERSION, '1337')"
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: de.jonasbark.swiftcontrol
releaseFiles: build/app/outputs/bundle/release/app-release.aab
track: production
windows:
needs: build
name: Build & Release on Windows

2
.gitignore vendored
View File

@@ -45,3 +45,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
service-account.json

View File

@@ -1,3 +1,12 @@
### 2.5.0 (2025-09-25)
- Improve usability
- SwiftControl is now available via the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol
- SwiftControl will continue to be available to download for free on GitHub
- contact me if you already donated and I'll get a voucher for you :)
### 2.4.0+1 (2025-09-17)
- Windows: fix mouse clicks at wrong location due to display scaling (fixes #64)
### 2.4.0 (2025-09-16)
- Show an overview of the keymap bindings
- Allow customizing an existing keymap

View File

@@ -11,6 +11,8 @@ With SwiftControl you can **control your favorite trainer app** using your Zwift
- control music on your device
- more? If you can do it via keyboard, mouse or touch, you can do it with SwiftControl
**Android AccessibilityService Usage**: On Android, SwiftControl uses the AccessibilityService API to simulate touch gestures on your screen, allowing your Zwift devices to control training apps. This service only monitors which app window is active and performs touch gestures at the locations you configure. No personal data is accessed or collected.
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
@@ -18,7 +20,9 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
## Downloads
Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img src="https://storage.googleapis.com/pe-portal-consumer-prod-wagtail-static/images/googleplay-badge-01-getit.max-1920x1070.format-webp.webp?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=wagtail%40pe-portal-consumer-prod.iam.gserviceaccount.com%2F20250925%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20250925T084315Z&X-Goog-Expires=86400&X-Goog-SignedHeaders=host&X-Goog-Signature=6eab941e460ae5973f162ce5740adf1e71cf8dd47fd5a9ba60ec673d31f807b0bea359f123a5f5151eb2315fac9c2aa641886e9fda8c545837274a04ca2e8c3217f54495f3b225ecf55a1ba1a34fe52836562583f387c62a4e140c64d1a13094d455a157df514bf7ea088ec2a2aa294ec5e594aea873ab3b63fc9f6d586ac15c04a0d05a4ec557bcb9cb9de48087508219ebf4bc5686dd8051c9949024baba1933cecdc6035b3766ff9fb9a9dd0c3418b225c155173d3b6911043244966a9df1f06ede2c5128fa7625d168c0c4bebf4e9b4c47439b4056c9fe9056e07399e85f3d875ac3478224e226d778fe8d9e7a8d54cae1a7dceb36494aa0326477ca7ffd" width="220"></a>
Get the latest version for free for Windows, macOS and Android here: https://github.com/jonasbark/swiftcontrol/releases
## Supported Apps
- MyWhoosh
@@ -41,6 +45,7 @@ Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
- Windows
- Windows may flag the app as virus. I think it does so because the app does control the mouse and keyboard.
- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).
- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70).
- [Web](https://jonasbark.github.io/swiftcontrol/) (you won't be able to do much)
- NOT SUPPORTED: iOS (iPhone, iPad) as Apple does not provide any way to simulate touches or keyboard events
@@ -50,14 +55,18 @@ Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
## How does it work?
The app connects to your Zwift device automatically. It does not connect to your trainer itself.
- When using Android a touch on a certain part of the screen is simulated to trigger the action.
- When using Android: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
- When using macOS or 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
## Alternatives
- [qdomyos-zwift](https://www.qzfitness.com/) directly controls the trainer (as opposed to controlling the trainer app)
## Donate
Please consider donating to support the development of this app :)
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/boni)
- [via PayPal](https://paypal.me/boni)
- [via CreditCard (USD)](https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201)
- [via CreditCard (EUR)](https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200)

View File

@@ -14,7 +14,7 @@ val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
android {
namespace = "de.jonasbark.swift_play"
namespace = "de.jonasbark.swiftcontrol"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
@@ -32,7 +32,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "de.jonasbark.swift_play"
applicationId = "de.jonasbark.swiftcontrol"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24

View File

@@ -1,4 +1,4 @@
package de.jonasbark.swift_play
package de.jonasbark.swiftcontrol
import io.flutter.embedding.android.FlutterActivity

View File

@@ -18,8 +18,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

View File

@@ -4,6 +4,7 @@
#include <windows.h>
#include <psapi.h>
#include <string.h>
#include <flutter_windows.h>
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
@@ -126,8 +127,18 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
y = std::get<double>(it_y->second);
}
// Get the monitor containing the target point and its DPI
const POINT target_point = {static_cast<LONG>(x), static_cast<LONG>(y)};
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
double scale_factor = dpi / 96.0;
// Scale the coordinates according to the DPI scaling
int scaled_x = static_cast<int>(x * scale_factor);
int scaled_y = static_cast<int>(y * scale_factor);
// Move the mouse to the specified coordinates
SetCursorPos(static_cast<int>(x), static_cast<int>(y));
SetCursorPos(scaled_x, scaled_y);
// Prepare input for mouse down and up
INPUT input = {0};

View File

@@ -163,6 +163,7 @@ class Connection {
}
void reset() {
_actionStreams.add(LogNotification('Disconnecting all devices'));
UniversalBle.stopScan();
isScanning.value = false;
for (var device in devices) {

View File

@@ -21,7 +21,9 @@ import '../messages/notification.dart';
abstract class BaseDevice {
final BleDevice scanResult;
BaseDevice(this.scanResult);
final List<ZwiftButton> availableButtons;
BaseDevice(this.scanResult, {required this.availableButtons});
final zapEncryption = ZapCrypto(LocalKeyProvider());

View File

@@ -5,7 +5,7 @@ import 'package:swift_control/utils/keymap/buttons.dart';
import '../messages/click_notification.dart';
class ZwiftClick extends BaseDevice {
ZwiftClick(super.scanResult);
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButton.shiftUpRight, ZwiftButton.shiftDownLeft]);
ClickNotification? _lastClickNotification;

View File

@@ -2,4 +2,7 @@ import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
class ZwiftClickV2 extends ZwiftRide {
ZwiftClickV2(super.scanResult);
@override
bool get supportsEncryption => false;
}

View File

@@ -8,7 +8,25 @@ import 'package:swift_control/utils/keymap/buttons.dart';
import '../ble.dart';
class ZwiftPlay extends BaseDevice {
ZwiftPlay(super.scanResult);
ZwiftPlay(super.scanResult)
: super(
availableButtons: [
ZwiftButton.y,
ZwiftButton.z,
ZwiftButton.a,
ZwiftButton.b,
ZwiftButton.onOffRight,
ZwiftButton.sideButtonRight,
ZwiftButton.paddleRight,
ZwiftButton.navigationUp,
ZwiftButton.navigationLeft,
ZwiftButton.navigationRight,
ZwiftButton.navigationDown,
ZwiftButton.onOffLeft,
ZwiftButton.sideButtonLeft,
ZwiftButton.paddleLeft,
],
);
PlayNotification? _lastControllerNotification;

View File

@@ -7,7 +7,29 @@ import 'package:swift_control/utils/keymap/buttons.dart';
import '../ble.dart';
class ZwiftRide extends BaseDevice {
ZwiftRide(super.scanResult);
ZwiftRide(super.scanResult)
: super(
availableButtons: [
ZwiftButton.navigationLeft,
ZwiftButton.navigationRight,
ZwiftButton.navigationUp,
ZwiftButton.navigationDown,
ZwiftButton.a,
ZwiftButton.b,
ZwiftButton.y,
ZwiftButton.z,
ZwiftButton.shiftUpLeft,
ZwiftButton.shiftDownLeft,
ZwiftButton.shiftUpRight,
ZwiftButton.shiftDownRight,
ZwiftButton.powerUpLeft,
ZwiftButton.powerUpRight,
ZwiftButton.onOffLeft,
ZwiftButton.onOffRight,
ZwiftButton.paddleLeft,
ZwiftButton.paddleRight,
],
);
@override
String get customServiceId => BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID;

View File

@@ -64,10 +64,14 @@ class _DevicePageState extends State<DevicePage> {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 10,
children: [
Text('Connected Devices:', style: Theme.of(context).textTheme.titleMedium),
Text(
'Devices:\n${connection.devices.joinToString(separator: '\n', transform: (it) {
return "${it.device.name ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}${it.batteryLevel != null ? ' - Battery Level: ${it.batteryLevel}%' : ''}";
})}',
connection.devices.joinToString(
separator: '\n',
transform: (it) {
return "${it.device.name ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}${it.batteryLevel != null ? ' - Battery Level: ${it.batteryLevel}%' : ''}";
},
),
),
Divider(color: Theme.of(context).colorScheme.primary, height: 30),
if (!kIsWeb)
@@ -78,6 +82,7 @@ class _DevicePageState extends State<DevicePage> {
children: [
Flex(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
direction: MediaQuery.sizeOf(context).width > 600 ? Axis.horizontal : Axis.vertical,
spacing: 8,
children: [
@@ -87,7 +92,7 @@ class _DevicePageState extends State<DevicePage> {
SupportedApp.supportedApps
.map((app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name))
.toList(),
label: Text('Select Keymap'),
label: Text('Select Keymap / app'),
onSelected: (app) async {
if (app == null) {
return;
@@ -110,39 +115,40 @@ class _DevicePageState extends State<DevicePage> {
hintText: 'Select your Keymap',
),
ElevatedButton(
onPressed: () async {
if (actionHandler.supportedApp! is! CustomApp) {
final customApp = CustomApp();
if (actionHandler.supportedApp != null)
ElevatedButton(
onPressed: () async {
if (actionHandler.supportedApp! is! CustomApp) {
final customApp = CustomApp();
actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) {
pair.buttons.forEachIndexed((button, indexB) {
customApp.setKey(
button,
physicalKey: pair.physicalKey!,
logicalKey: pair.logicalKey,
isLongPress: pair.isLongPress,
touchPosition:
pair.touchPosition != Offset.zero
? pair.touchPosition
: Offset(((indexB + 1)) * 100, 200 + (index * 100)),
);
actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) {
pair.buttons.forEachIndexed((button, indexB) {
customApp.setKey(
button,
physicalKey: pair.physicalKey!,
logicalKey: pair.logicalKey,
isLongPress: pair.isLongPress,
touchPosition:
pair.touchPosition != Offset.zero
? pair.touchPosition
: Offset(((indexB + 1)) * 100, 200 + (index * 100)),
);
});
});
});
actionHandler.supportedApp = customApp;
settings.setApp(customApp);
}
final result = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
if (result == true && actionHandler.supportedApp is CustomApp) {
settings.setApp(actionHandler.supportedApp!);
}
setState(() {});
},
child: Text('Customize Keymap'),
),
actionHandler.supportedApp = customApp;
settings.setApp(customApp);
}
final result = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
if (result == true && actionHandler.supportedApp is CustomApp) {
settings.setApp(actionHandler.supportedApp!);
}
setState(() {});
},
child: Text('Customize Keymap'),
),
],
),
if (actionHandler.supportedApp != null)

View File

@@ -31,7 +31,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
WidgetsBinding.instance.addPostFrameCallback((_) {
settings.init().then((_) {
if (!kIsWeb && Platform.isMacOS) {
// add more delay due tu CBManagerStateUnknown
// add more delay due to CBManagerStateUnknown
Future.delayed(const Duration(seconds: 2), () {
_reloadRequirements();
});
@@ -72,55 +72,68 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
body:
_requirements.isEmpty
? Center(child: CircularProgressIndicator())
: Stepper(
currentStep: _currentStep,
connectorColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
),
onStepContinue:
_currentStep < _requirements.length
? () {
setState(() {
_currentStep += 1;
});
: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0),
child: Text(
'Please complete the following requirements to make the app work correctly:',
style: Theme.of(context).textTheme.titleMedium,
),
),
Expanded(
child: Stepper(
currentStep: _currentStep,
connectorColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
),
onStepContinue:
_currentStep < _requirements.length
? () {
setState(() {
_currentStep += 1;
});
}
: null,
onStepTapped: (step) {
if (_requirements[step].status) {
return;
}
: null,
onStepTapped: (step) {
if (_requirements[step].status) {
return;
}
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
if (hasEarlierIncomplete) {
return;
}
setState(() {
_currentStep = step;
});
},
controlsBuilder: (context, details) => Container(),
steps:
_requirements
.mapIndexed(
(index, req) => Step(
title: Text(req.name),
content: Container(
padding: const EdgeInsets.symmetric(vertical: 16.0),
alignment: Alignment.centerLeft,
child:
(index == _currentStep
? req.build(context, () {
_reloadRequirements();
})
: null) ??
ElevatedButton(
onPressed: req.status ? null : () => _callRequirement(req),
child: Text(req.name),
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
if (hasEarlierIncomplete) {
return;
}
setState(() {
_currentStep = step;
});
},
controlsBuilder: (context, details) => Container(),
steps:
_requirements
.mapIndexed(
(index, req) => Step(
title: Text(req.name),
content: Container(
padding: const EdgeInsets.symmetric(vertical: 16.0),
alignment: Alignment.centerLeft,
child:
(index == _currentStep
? req.build(context, () {
_reloadRequirements();
})
: null) ??
ElevatedButton(
onPressed: req.status ? null : () => _callRequirement(req),
child: Text(req.name),
),
),
),
state: req.status ? StepState.complete : StepState.indexed,
),
)
.toList(),
state: req.status ? StepState.complete : StepState.indexed,
),
)
.toList(),
),
),
],
),
);
}

View File

@@ -45,9 +45,7 @@ class _ScanWidgetState extends State<ScanWidget> {
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(minHeight: 200),
child: ListView(
padding: EdgeInsets.all(16),
shrinkWrap: true,
child: Column(
children: [
ValueListenableBuilder(
valueListenable: connection.isScanning,

View File

@@ -1,9 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/accessibility_disclosure_dialog.dart';
class AccessibilityRequirement extends PlatformRequirement {
AccessibilityRequirement() : super('Allow Accessibility Service');
@@ -17,6 +19,53 @@ class AccessibilityRequirement extends PlatformRequirement {
Future<void> getStatus() async {
status = await accessibilityHandler.hasPermission();
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
if (status) {
return null; // Already granted, no need for disclosure
}
return Container(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'SwiftControl needs accessibility permission to control your training apps.',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _showDisclosureDialog(context, onUpdate),
child: const Text('Show Permission Details'),
),
],
),
);
}
Future<void> _showDisclosureDialog(BuildContext context, VoidCallback onUpdate) async {
return showDialog<void>(
context: context,
barrierDismissible: false, // Prevent dismissing by tapping outside
builder: (BuildContext context) {
return AccessibilityDisclosureDialog(
onAccept: () {
Navigator.of(context).pop();
// Open accessibility settings after user consents
accessibilityHandler.openPermissions().then((_) {
onUpdate();
});
},
onDeny: () {
Navigator.of(context).pop();
// User denied, no action taken
},
);
},
);
}
}
class BluetoothScanRequirement extends PlatformRequirement {

View File

@@ -46,7 +46,7 @@ class UnsupportedPlatform extends PlatformRequirement {
}
class BluetoothScanning extends PlatformRequirement {
BluetoothScanning() : super('Bluetooth Scanning') {
BluetoothScanning() : super('Finding your Zwift® controller...') {
status = false;
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
class AccessibilityDisclosureDialog extends StatelessWidget {
final VoidCallback onAccept;
final VoidCallback onDeny;
const AccessibilityDisclosureDialog({
super.key,
required this.onAccept,
required this.onDeny,
});
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false, // Prevent back navigation from dismissing dialog
child: AlertDialog(
title: const Text('Accessibility Service Permission Required'),
content: const SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SwiftControl needs to use Android\'s AccessibilityService API to function properly.',
style: TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(height: 16),
Text('Why is this permission needed?'),
SizedBox(height: 8),
Text('• To simulate touch gestures on your screen for controlling trainer apps'),
Text('• To detect which training app window is currently active'),
Text('• To enable you to control apps like MyWhoosh, IndieVelo, and others using your Zwift devices'),
SizedBox(height: 16),
Text(
'How does SwiftControl use this permission?',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
Text('• When you press buttons on your Zwift Click, Zwift Ride, or Zwift Play devices, SwiftControl simulates touch gestures at specific screen locations'),
Text('• The app monitors which training app window is active to ensure gestures are sent to the correct app'),
Text('• No personal data is accessed or collected through this service'),
SizedBox(height: 16),
Text(
'SwiftControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.',
style: TextStyle(fontStyle: FontStyle.italic),
),
SizedBox(height: 16),
Text(
'You must choose to either Allow or Deny this permission to continue.',
style: TextStyle(fontWeight: FontWeight.w600, color: Colors.deepOrange),
),
],
),
),
actions: [
TextButton(
onPressed: onDeny,
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: const Text('Deny'),
),
ElevatedButton(
onPressed: onAccept,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
child: const Text('Allow'),
),
],
),
);
}
}

View File

@@ -10,10 +10,16 @@ class KeymapExplanation extends StatelessWidget {
@override
Widget build(BuildContext context) {
final keyboardGroups = keymap.keyPairs
final connectedDevice = connection.devices.firstOrNull;
final availableKeypairs = keymap.keyPairs.filter(
(e) => connectedDevice?.availableButtons.containsAny(e.buttons) == true,
);
final keyboardGroups = availableKeypairs
.filter((e) => e.physicalKey != null)
.groupBy((element) => '${element.physicalKey}-${element.isLongPress}');
final touchGroups = keymap.keyPairs
final touchGroups = availableKeypairs
.filter((e) => e.physicalKey == null && e.touchPosition != Offset.zero)
.groupBy((element) => '${element.touchPosition}-${element.isLongPress}');
@@ -33,7 +39,7 @@ class KeymapExplanation extends StatelessWidget {
Padding(
padding: const EdgeInsets.all(6),
child: Text(
'Button on your ${connection.devices.firstOrNull?.device.name ?? connection.devices.firstOrNull?.runtimeType}',
'Button on your ${connectedDevice?.device.name ?? connectedDevice?.runtimeType}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
@@ -57,7 +63,8 @@ class KeymapExplanation extends StatelessWidget {
children: [
for (final keyPair in pair.value)
for (final button in keyPair.buttons)
IntrinsicWidth(child: _KeyWidget(label: button.name.splitByUpperCase())),
if (connectedDevice?.availableButtons.contains(button) == true)
IntrinsicWidth(child: _KeyWidget(label: button.name.splitByUpperCase())),
],
),
),
@@ -84,7 +91,9 @@ class KeymapExplanation extends StatelessWidget {
spacing: 8,
children: [
for (final keyPair in pair.value)
for (final button in keyPair.buttons) _KeyWidget(label: button.name.splitByUpperCase()),
for (final button in keyPair.buttons)
if (connectedDevice?.availableButtons.contains(button) == true)
_KeyWidget(label: button.name.splitByUpperCase()),
],
),
),

View File

@@ -48,52 +48,45 @@ class _LogviewerState extends State<LogViewer> {
@override
Widget build(BuildContext context) {
return Stack(
children: [
SelectionArea(
child: ListView(
controller: _scrollController,
children:
_actions
.map(
(action) => Text.rich(
TextSpan(
children: [
TextSpan(
text: action.date.toString().split(" ").last,
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: "monospace",
fontFamilyFallback: <String>["Courier"],
),
),
TextSpan(
text: " ${action.entry}",
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.bold,
),
),
],
),
),
)
.toList(),
return SelectionArea(
child: ListView(
controller: _scrollController,
children: [
..._actions.map(
(action) => Text.rich(
TextSpan(
children: [
TextSpan(
text: action.date.toString().split(" ").last,
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: "monospace",
fontFamilyFallback: <String>["Courier"],
),
),
TextSpan(
text: " ${action.entry}",
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
Align(
alignment: Alignment.topRight,
child: IconButton(
TextButton(
onPressed: () {
_actions.clear();
setState(() {});
},
icon: Icon(Icons.clear),
child: Text('Clear Log'),
),
),
],
],
),
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/widgets/title.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../pages/device.dart';
@@ -17,7 +18,7 @@ List<Widget> buildMenuButtons() {
PopupMenuItem(
child: Text('via Credit Card, Google Pay, Apple Pay and others'),
onTap: () {
final currency = NumberFormat.simpleCurrency(locale: Platform.localeName);
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
final link = switch (currency.currencyName) {
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
@@ -25,6 +26,13 @@ List<Widget> buildMenuButtons() {
launchUrlString(link);
},
),
if (!kIsWeb && Platform.isAndroid && !isFromPlayStore)
PopupMenuItem(
child: Text('by buying the app from Play Store'),
onTap: () {
launchUrlString('https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol');
},
),
PopupMenuItem(
child: Text('via PayPal'),
onTap: () {

View File

@@ -5,12 +5,14 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:in_app_update/in_app_update.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import 'package:url_launcher/url_launcher_string.dart';
String? _latestVersionUrlValue;
PackageInfo? _packageInfoValue;
bool isFromPlayStore = true;
class AppTitle extends StatefulWidget {
const AppTitle({super.key});
@@ -20,7 +22,7 @@ class AppTitle extends StatefulWidget {
}
class _AppTitleState extends State<AppTitle> {
Future<String?> getLatestVersionUrlIfNewer() async {
Future<String?> _getLatestVersionUrlIfNewer() async {
final response = await http.get(Uri.parse('https://api.github.com/repos/jonasbark/swiftcontrol/releases/latest'));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
@@ -28,8 +30,8 @@ class _AppTitleState extends State<AppTitle> {
final latestVersion = tagName.split('+').first;
final currentVersion = 'v${_packageInfoValue!.version}';
// we anything but +0 is considered beta
if (latestVersion != currentVersion && tagName.endsWith("+0")) {
// +1337 releases are considered beta
if (latestVersion != currentVersion && !tagName.endsWith("+1337")) {
final assets = data['assets'] as List;
if (Platform.isAndroid) {
final apkUrl = assets.firstOrNullWhere((asset) => asset['name'].endsWith('.apk'))['browser_download_url'];
@@ -56,16 +58,39 @@ class _AppTitleState extends State<AppTitle> {
setState(() {
_packageInfoValue = value;
});
_loadLatestVersionUrl();
_checkForUpdate();
});
} else {
_loadLatestVersionUrl();
_checkForUpdate();
}
}
void _loadLatestVersionUrl() async {
void _checkForUpdate() async {
if (Platform.isAndroid) {
try {
final appUpdateInfo = await InAppUpdate.checkForUpdate();
if (context.mounted && appUpdateInfo.updateAvailability == UpdateAvailability.updateAvailable) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('New version available'),
duration: Duration(seconds: 1337),
action: SnackBarAction(
label: 'Update',
onPressed: () {
InAppUpdate.performImmediateUpdate();
},
),
),
);
}
return null;
} on Exception catch (e) {
isFromPlayStore = false;
print('Failed to check for update: $e');
}
}
if (_latestVersionUrlValue == null && !kIsWeb) {
final url = await getLatestVersionUrlIfNewer();
final url = await _getLatestVersionUrlIfNewer();
if (url != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(

BIN
playstoreassets/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

BIN
playstoreassets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

BIN
playstoreassets/mob1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

BIN
playstoreassets/mob2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
playstoreassets/tab1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

BIN
playstoreassets/tab2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

View File

@@ -391,6 +391,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
in_app_update:
dependency: "direct main"
description:
name: in_app_update
sha256: "9924a3efe592e1c0ec89dda3683b3cfec3d4cd02d908e6de00c24b759038ddb1"
url: "https://pub.dev"
source: hosted
version: "4.2.5"
intl:
dependency: "direct main"
description:

View File

@@ -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: 2.4.0+0
version: 2.5.0+5
environment:
sdk: ^3.7.0
@@ -26,6 +26,7 @@ dependencies:
shared_preferences: ^2.5.3
flex_color_scheme: ^8.3.0
package_info_plus: ^8.3.0
in_app_update: ^4.2.5
accessibility:
path: accessibility
http: ^1.3.0

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_control/widgets/accessibility_disclosure_dialog.dart';
void main() {
group('AccessibilityDisclosureDialog', () {
testWidgets('shows proper consent options with two buttons', (WidgetTester tester) async {
bool acceptCalled = false;
bool denyCalled = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: AccessibilityDisclosureDialog(
onAccept: () => acceptCalled = true,
onDeny: () => denyCalled = true,
),
),
),
);
// Verify dialog shows proper title
expect(find.text('Accessibility Service Permission Required'), findsOneWidget);
// Verify both consent options are present
expect(find.text('Allow'), findsOneWidget);
expect(find.text('Deny'), findsOneWidget);
// Verify explanation text is present
expect(find.textContaining('AccessibilityService API'), findsOneWidget);
expect(find.textContaining('simulate touch gestures'), findsOneWidget);
expect(find.textContaining('No personal data'), findsOneWidget);
// Test deny button
await tester.tap(find.text('Deny'));
await tester.pump();
expect(denyCalled, isTrue);
// Reset and test accept button
denyCalled = false;
await tester.tap(find.text('Allow'));
await tester.pump();
expect(acceptCalled, isTrue);
});
testWidgets('includes required disclosure information', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: AccessibilityDisclosureDialog(
onAccept: () {},
onDeny: () {},
),
),
),
);
// Check for key disclosure elements required by Play Store
expect(find.textContaining('Why is this permission needed?'), findsOneWidget);
expect(find.textContaining('How does SwiftControl use this permission?'), findsOneWidget);
expect(find.textContaining('Zwift Click, Zwift Ride, or Zwift Play'), findsOneWidget);
expect(find.textContaining('training app window is active'), findsOneWidget);
expect(find.textContaining('You must choose to either Allow or Deny'), findsOneWidget);
});
testWidgets('prevents dismissal via back navigation', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: AccessibilityDisclosureDialog(
onAccept: () {},
onDeny: () {},
),
),
),
);
// Verify PopScope is present to prevent back navigation
expect(find.byType(PopScope), findsOneWidget);
// Get the PopScope widget and verify canPop is false
final popScope = tester.widget<PopScope>(find.byType(PopScope));
expect(popScope.canPop, isFalse);
});
});
}