Compare commits

...

35 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
Jonas Bark
bbd01d023a - Show an overview of the keymap bindings
- Allow customizing an existing keymap
2025-09-16 10:32:09 +02:00
Jonas Bark
36282c9fa9 better donate options 2025-09-16 08:59:50 +02:00
jonasbark
daea07c409 Clarify iOS not being supported 2025-09-15 08:08:07 +02:00
jonasbark
49d7445d0e Aktualisieren von README.md 2025-09-11 21:14:32 +02:00
jonasbark
9bb0e5616a Aktualisieren von pubspec.yaml 2025-09-11 19:27:47 +02:00
jonasbark
7e98f595ee Aktualisieren von CHANGELOG.md 2025-09-11 19:27:18 +02:00
Jonas Bark
a9fdc4b16e attempt to add support for Zwift Click v2 2025-09-10 17:40:14 +02:00
37 changed files with 767 additions and 203 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,5 +1,19 @@
### 2.2.1 (2025-09-09) BETA
- Attempt to add support for latest Zwift Click v2
### 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
- Add more donation options
### 2.3.0 (2025-09-11)
- Add support for latest Zwift Click v2
### 2.2.0 (2025-09-08)
- Add Long Press Mode option for custom keymaps - buttons can now send sustained key presses instead of repeated taps, perfect for movement controls in games (fixes #61)

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
@@ -30,6 +34,7 @@ Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
## Supported Devices
- Zwift Click
- Zwift Click v2
- Zwift Ride
- Zwift Play
@@ -40,7 +45,9 @@ 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
## Troubleshooting
- Your Zwift device is found but connection does not work properly? You may need to update the firmware in Zwift Companion app.
@@ -48,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());
@@ -307,12 +309,12 @@ abstract class BaseDevice {
Future<void> _vibrate() async {
final vibrateCommand = Uint8List.fromList([...Constants.VIBRATE_PATTERN, 0x20]);
await UniversalBle.writeValue(
await UniversalBle.write(
device.deviceId,
customServiceId,
syncRxCharacteristic!.uuid,
supportsEncryption ? zapEncryption.encrypt(vibrateCommand) : vibrateCommand,
BleOutputProperty.withoutResponse,
withoutResponse: true,
);
}

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

@@ -1,5 +1,8 @@
import 'package:swift_control/bluetooth/devices/zwift_play.dart';
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
class ZwiftClickV2 extends ZwiftPlay {
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

@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/touch_area.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/logviewer.dart';
import 'package:swift_control/widgets/title.dart';
@@ -57,93 +58,111 @@ class _DevicePageState extends State<DevicePage> {
actions: buildMenuButtons(),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Padding(
body: SingleChildScrollView(
padding: const EdgeInsets.all(8.0),
child: Column(
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)
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownMenu<SupportedApp>(
controller: controller,
dropdownMenuEntries:
SupportedApp.supportedApps
.map(
(app) => DropdownMenuEntry<SupportedApp>(
value: app,
label: app.name,
trailingIcon: IconButton(
icon: Icon(Icons.info_outline),
onPressed: () {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: Text(app.name),
content: SelectableText(app.keymap.toString()),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('OK'),
),
],
),
);
},
Flex(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
direction: MediaQuery.sizeOf(context).width > 600 ? Axis.horizontal : Axis.vertical,
spacing: 8,
children: [
DropdownMenu<SupportedApp>(
controller: controller,
dropdownMenuEntries:
SupportedApp.supportedApps
.map((app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name))
.toList(),
label: Text('Select Keymap / app'),
onSelected: (app) async {
if (app == null) {
return;
}
controller.text = app.name ?? '';
actionHandler.supportedApp = app;
settings.setApp(app);
setState(() {});
if (app is! CustomApp && !kIsWeb && (Platform.isMacOS || Platform.isWindows)) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text(
'Customize the keymap if you experience any issues (e.g. wrong keyboard output)',
),
),
)
.toList(),
label: Text('Select Keymap'),
onSelected: (app) async {
if (app == null) {
return;
}
controller.text = app.name ?? '';
actionHandler.supportedApp = app;
settings.setApp(app);
setState(() {});
if (app is! CustomApp && !kIsWeb && (Platform.isMacOS || Platform.isWindows)) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text(
'Use Custom keymap if you experience any issues (e.g. wrong keyboard output)',
),
),
);
}
},
initialSelection: actionHandler.supportedApp,
hintText: 'Select your Keymap',
),
);
}
},
initialSelection: actionHandler.supportedApp,
hintText: 'Select your Keymap',
),
if (actionHandler.supportedApp is CustomApp)
ElevatedButton(
onPressed: () async {
final result = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
if (result == true && actionHandler.supportedApp is CustomApp) {
settings.setApp(actionHandler.supportedApp!);
}
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 = 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)
KeymapExplanation(
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
keymap: actionHandler.supportedApp!.keymap,
onUpdate: () {
setState(() {});
controller.text = actionHandler.supportedApp?.name ?? '';
},
child: Text('Customize Keymap'),
),
],
),
Expanded(child: LogViewer()),
SizedBox(height: 800, child: LogViewer()),
],
),
),

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

@@ -350,7 +350,7 @@ class _TouchDot extends StatelessWidget {
? Icons.music_note_outlined
: keyPair.physicalKey != null
? Icons.keyboard_alt_outlined
: Icons.add,
: Icons.touch_app_outlined,
),
),

View File

@@ -43,6 +43,7 @@ class CustomApp extends SupportedApp {
required PhysicalKeyboardKey physicalKey,
required LogicalKeyboardKey? logicalKey,
bool isLongPress = false,
Offset? touchPosition,
}) {
// set the key for the zwift button
final keyPair = keymap.getKeyPair(zwiftButton);
@@ -50,13 +51,17 @@ class CustomApp extends SupportedApp {
keyPair.physicalKey = physicalKey;
keyPair.logicalKey = logicalKey;
keyPair.isLongPress = isLongPress;
keyPair.touchPosition = touchPosition ?? Offset.zero;
} else {
keymap.keyPairs.add(KeyPair(
buttons: [zwiftButton],
physicalKey: physicalKey,
logicalKey: logicalKey,
isLongPress: isLongPress,
));
keymap.keyPairs.add(
KeyPair(
buttons: [zwiftButton],
physicalKey: physicalKey,
logicalKey: logicalKey,
isLongPress: isLongPress,
touchPosition: touchPosition ?? Offset.zero,
),
);
}
}
}

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

@@ -71,6 +71,7 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
_pressedButton!,
physicalKey: _pressedKey!.physicalKey,
logicalKey: _pressedKey!.logicalKey,
touchPosition: widget.keyPair?.touchPosition,
);
}
});

View File

@@ -0,0 +1,157 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
class KeymapExplanation extends StatelessWidget {
final Keymap keymap;
final VoidCallback onUpdate;
const KeymapExplanation({super.key, required this.keymap, required this.onUpdate});
@override
Widget build(BuildContext context) {
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 = availableKeypairs
.filter((e) => e.physicalKey == null && e.touchPosition != Offset.zero)
.groupBy((element) => '${element.touchPosition}-${element.isLongPress}');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
if (keymap.keyPairs.isEmpty)
Text('No key mappings found. Please customize the keymap.')
else
Table(
border: TableBorder.all(color: Theme.of(context).colorScheme.primaryContainer),
children: [
TableRow(
children: [
Padding(
padding: const EdgeInsets.all(6),
child: Text(
'Button on your ${connectedDevice?.device.name ?? connectedDevice?.runtimeType}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.all(6),
child: Text(
'Action',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
],
),
for (final pair in keyboardGroups.entries) ...[
TableRow(
children: [
Padding(
padding: const EdgeInsets.all(6),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final keyPair in pair.value)
for (final button in keyPair.buttons)
if (connectedDevice?.availableButtons.contains(button) == true)
IntrinsicWidth(child: _KeyWidget(label: button.name.splitByUpperCase())),
],
),
),
Padding(
padding: const EdgeInsets.all(6),
child: Row(
spacing: 8,
children: [
Icon(Icons.keyboard, size: 16),
_KeyWidget(label: pair.value.first.logicalKey?.keyLabel ?? ''),
if (pair.value.first.isLongPress) Text('using long press'),
],
),
),
],
),
],
for (final pair in touchGroups.entries) ...[
TableRow(
children: [
Padding(
padding: const EdgeInsets.all(6),
child: Row(
spacing: 8,
children: [
for (final keyPair in pair.value)
for (final button in keyPair.buttons)
if (connectedDevice?.availableButtons.contains(button) == true)
_KeyWidget(label: button.name.splitByUpperCase()),
],
),
),
Padding(
padding: const EdgeInsets.all(6),
child: Row(
spacing: 8,
children: [
Icon(Icons.touch_app, size: 16),
_KeyWidget(
label:
'x: ${pair.value.first.touchPosition.dx.toInt()}, y: ${pair.value.first.touchPosition.dy.toInt()}',
),
if (pair.value.first.isLongPress) Text('using long press'),
],
),
),
],
),
],
],
),
],
);
}
}
class _KeyWidget extends StatelessWidget {
final String label;
const _KeyWidget({super.key, required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
constraints: BoxConstraints(minWidth: 30),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.primary),
borderRadius: BorderRadius.circular(4),
color: Theme.of(context).colorScheme.primaryContainer,
),
child: Center(
child: Text(
label,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
);
}
}
extension on String {
String splitByUpperCase() {
return replaceAllMapped(RegExp(r'([a-z])([A-Z])'), (match) => '${match.group(1)} ${match.group(2)}').capitalize();
}
}

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

@@ -1,24 +1,42 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
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';
List<Widget> buildMenuButtons() {
return [
PopupMenuButton(
itemBuilder: (BuildContext context) {
return [
PopupMenuItem(
child: Text('PayPal'),
child: Text('via Credit Card, Google Pay, Apple Pay and others'),
onTap: () {
launchUrlString('https://paypal.me/boni');
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
final link = switch (currency.currencyName) {
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
};
launchUrlString(link);
},
),
if (!kIsWeb && Platform.isAndroid && !isFromPlayStore)
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('Other'),
child: Text('via PayPal'),
onTap: () {
launchUrlString('https://github.com/sponsors/jonasbark?frequency=one-time');
launchUrlString('https://paypal.me/boni');
},
),
];
@@ -51,6 +69,13 @@ class MenuButton extends StatelessWidget {
),
),
PopupMenuItem(child: PopupMenuDivider()),
PopupMenuItem(
child: Text('Continue'),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
},
),
PopupMenuItem(child: PopupMenuDivider()),
],
PopupMenuItem(
child: Text('Feedback'),

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(
@@ -91,7 +116,7 @@ class _AppTitleState extends State<AppTitle> {
Text('SwiftControl'),
if (_packageInfoValue != null)
Text(
'v${_packageInfoValue!.version}',
'v${_packageInfoValue!.version}+${_packageInfoValue!.buildNumber}',
style: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
)
else

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,22 @@ 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:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
json_annotation:
dependency: transitive
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.2.1+2
version: 2.5.0+5
environment:
sdk: ^3.7.0
@@ -13,6 +13,7 @@ dependencies:
url_launcher: ^6.3.1
flutter_local_notifications: ^19.4.1
universal_ble: ^0.21.1
intl: any
protobuf: ^3.1.0
permission_handler: ^11.4.0
dartx: any
@@ -25,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);
});
});
}