Compare commits

..

8 Commits

Author SHA1 Message Date
Jonas Bark
dc63f693f0 fix double clicks in some apps 2025-10-02 18:10:12 +02:00
Jonas Bark
455db754d8 fix #82 2025-10-01 20:33:56 +02:00
Jonas Bark
cbef8fc044 fix #82 2025-10-01 20:23:02 +02:00
Jonas Bark
d8e45f849a ui fixes, increase repeating timer 2025-10-01 20:11:01 +02:00
Jonas Bark
f83defb37b introduce workaround for Zwift Click V2 (reset every minute) 2025-10-01 16:17:20 +02:00
Jonas Bark
5c8db11536 Merge branch 'web' 2025-10-01 15:42:43 +02:00
Jonas Bark
30aa5b33a3 fix issue #81 2025-10-01 15:42:20 +02:00
Jonas Bark
ca41e69a17 fix issue #81 2025-10-01 15:41:19 +02:00
12 changed files with 145 additions and 134 deletions

View File

View File

@@ -1,3 +1,8 @@
### 2.6.3 (2025-10-01)
- fix a few issues with the new touch placement feature
- add a workaround for Zwift Click V2 which resets the device when button events are no longer sent
- fix issue on Android and Desktop where only a "touch down" was sent, but no "touch up"
### 2.6.0 (2025-09-30)
- refactor touch placements: show touches on screen, fix misplaced coordinates - should fix #64
- show firmware version of connected device

View File

@@ -272,6 +272,9 @@ abstract class BaseDevice {
Future<List<ZwiftButton>?> processClickNotification(Uint8List message);
Future<void> handleButtonsClicked(List<ZwiftButton>? buttonsClicked) async {
final isLongPress =
buttonsClicked?.singleOrNull != null &&
actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked!.single)?.isLongPress == true;
if (buttonsClicked == null) {
// ignore, no changes
} else if (buttonsClicked.isEmpty) {
@@ -280,33 +283,28 @@ abstract class BaseDevice {
// Handle release events for long press keys
final buttonsReleased = _previouslyPressedButtons.toList();
if (buttonsReleased.isNotEmpty) {
if (buttonsReleased.isNotEmpty && isLongPress) {
await _performRelease(buttonsReleased);
}
_previouslyPressedButtons.clear();
} else {
// Handle release events for buttons that are no longer pressed
final buttonsReleased = _previouslyPressedButtons.difference(buttonsClicked.toSet()).toList();
if (buttonsReleased.isNotEmpty) {
if (buttonsReleased.isNotEmpty && isLongPress) {
await _performRelease(buttonsReleased);
}
final isLongPress =
buttonsClicked.singleOrNull != null &&
actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
if (!isLongPress &&
!(buttonsClicked.singleOrNull == ZwiftButton.onOffLeft ||
buttonsClicked.singleOrNull == ZwiftButton.onOffRight)) {
// we don't want to trigger the long press timer for the on/off buttons, also not when it's a long press key
_longPressTimer?.cancel();
_longPressTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) async {
_longPressTimer = Timer.periodic(const Duration(milliseconds: 350), (timer) async {
_performActions(buttonsClicked, true);
});
} else if (isLongPress) {
// Update currently pressed buttons
_previouslyPressedButtons = buttonsClicked.toSet();
}
// Update currently pressed buttons
_previouslyPressedButtons = buttonsClicked.toSet();
return _performActions(buttonsClicked, false);
}
@@ -349,11 +347,11 @@ abstract class BaseDevice {
Future<void> disconnect() async {
_isInited = false;
_longPressTimer?.cancel();
_previouslyPressedButtons.clear();
// Release any held keys in long press mode
if (actionHandler is DesktopActions) {
await (actionHandler as DesktopActions).releaseAllHeldKeys();
await (actionHandler as DesktopActions).releaseAllHeldKeys(_previouslyPressedButtons.toList());
}
_previouslyPressedButtons.clear();
await UniversalBle.disconnect(device.deviceId);
isConnected = false;
}

View File

@@ -1,9 +1,9 @@
import 'dart:typed_data';
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
import 'package:swift_control/bluetooth/protocol/zp.pb.dart';
import '../ble.dart';
import '../protocol/zp.pbenum.dart';
class ZwiftClickV2 extends ZwiftRide {
ZwiftClickV2(super.scanResult);
@@ -44,27 +44,27 @@ class ZwiftClickV2 extends ZwiftRide {
0x00,
0x0A,
0x15,
0x40,
0xE9,
0xD9,
0xC9,
0x6B,
0x74,
0x63,
0xC2,
0x7F,
0x1B,
0x4E,
0x4D,
0x9F,
0x1C,
0xB1,
0x20,
0x5D,
0x88,
0x2E,
0xD7,
0xCE,
0x63,
0x24,
0x0A,
0x31,
0xD6,
0xC6,
0xB8,
0x1F,
0xC1,
0x29,
0xD6,
0xA4,
0xE9,
0x9D,
0xFF,
0xFC,
0xB9,
0xFC,
0x41,
0x8D,
]),
);*/
}

View File

@@ -2,6 +2,7 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:protobuf/protobuf.dart' as $pb;
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
import 'package:swift_control/bluetooth/messages/ride_notification.dart';
import 'package:swift_control/bluetooth/protocol/zp_vendor.pb.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
@@ -77,10 +78,15 @@ class ZwiftRide extends BaseDevice {
);
}
if (bytes.startsWith(Constants.RESPONSE_STOPPED_CLICK_V2)) {
if (bytes.startsWith(Constants.RESPONSE_STOPPED_CLICK_V2) && this is ZwiftClickV2) {
actionStreamInternal.add(
LogNotification('Your Zwift Click V2 no longer sends events. Connect it in the Zwift app once per day.'),
LogNotification(
'Your Zwift Click V2 no longer sends events. Connect it in the Zwift app once per day. Resetting the device now.',
),
);
if (!kDebugMode) {
sendCommand(Opcode.RESET, null);
}
}
switch (opcode) {

View File

@@ -78,10 +78,12 @@ class _DevicePageState extends State<DevicePage> {
),
padding: const EdgeInsets.all(8),
child: Text(
'''To make your Zwift Click V2 work properly you need to connect it to with in the Zwift app once each day:
'''To make your Zwift Click V2 work best you should connect it in the Zwift app once each day.\nIf you don't do that SwiftControl will need to reconnect every minute.
1. Open Zwift app
2. After logging in (subscription not required) find it in the device connection screen and connect it
3. Close the Zwift app again and connect again in SwiftControl''',
2. Log in (subscription not required) and open the device connection screen
3. Connect your Trainer, then connect the Zwift Click V2
4. Close the Zwift app again and connect again in SwiftControl''',
),
),
Text(
@@ -201,12 +203,13 @@ ${it.firmwareVersion != null ? ' - Firmware Version: ${it.firmwareVersion}' : ''
setState(() {});
},
),
if (connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
if (kDebugMode &&
connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
ElevatedButton(
onPressed: () {
(connection.devices.first as ZwiftClickV2).test();
},
child: Text('Reset'),
child: Text('Test'),
),
],
),

View File

@@ -74,7 +74,10 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
// Force landscape orientation during keymap editing
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []).then((_) {
// this will make sure the buttons are placed correctly after the transition
setState(() {});
});
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
windowManager.setFullScreen(true);
}
@@ -128,9 +131,11 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
}) {
final flutterView = WidgetsBinding.instance.platformDispatcher.views.first;
// figure out notch height for e.g. macOS
// figure out notch height for e.g. macOS. On Windows the display size is not available (0,0).
final differenceInHeight =
(flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio;
(flutterView.display.size.height > 0)
? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio
: 0.0;
if (kDebugMode) {
print('Display Size: ${flutterView.display.size}');
@@ -371,7 +376,7 @@ class KeypairExplanation extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
return Wrap(
spacing: 4,
children: [
if (withKey) KeyWidget(label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n')),

View File

@@ -44,7 +44,11 @@ class AndroidActions extends BaseActions {
final point = supportedApp!.resolveTouchPosition(action: button, windowInfo: windowInfo);
if (point != Offset.zero) {
accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()}";
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown
? "down"
: isKeyUp
? "up"
: "click"}";
}
return "No touch performed";
}

View File

@@ -5,7 +5,6 @@ import 'package:swift_control/widgets/keymap_explanation.dart';
class DesktopActions extends BaseActions {
// Track keys that are currently held down in long press mode
final Set<ZwiftButton> _heldKeys = <ZwiftButton>{};
@override
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
@@ -18,60 +17,42 @@ class DesktopActions extends BaseActions {
return ('Keymap entry not found for action: ${action.toString().splitByUpperCase()}');
}
// Handle long press mode
if (keyPair.isLongPress) {
if (isKeyDown && !isKeyUp) {
// Key press: start long press
if (!_heldKeys.contains(action)) {
_heldKeys.add(action);
if (keyPair.physicalKey != null) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
return 'Long press started: ${keyPair.logicalKey?.keyLabel}';
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
await keyPressSimulator.simulateMouseClickDown(point);
return 'Long Mouse click started at: $point';
}
}
} else if (isKeyUp && !isKeyDown) {
// Key release: end long press
if (_heldKeys.contains(action)) {
_heldKeys.remove(action);
if (keyPair.physicalKey != null) {
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
return 'Long press ended: ${keyPair.logicalKey?.keyLabel}';
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
await keyPressSimulator.simulateMouseClickUp(point);
return 'Long Mouse click ended at: $point';
}
}
}
// Ignore other combinations in long press mode
return 'Long press active';
} else {
// Handle regular key press mode (existing behavior)
if (keyPair.physicalKey != null) {
// Handle regular key press mode (existing behavior)
if (keyPair.physicalKey != null) {
if (isKeyDown) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
return 'Key pressed: $keyPair';
} else if (isKeyUp) {
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
return 'Key released: $keyPair';
} else {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
return 'Key pressed: $keyPair';
return 'Key clicked: $keyPair';
}
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
if (isKeyDown) {
await keyPressSimulator.simulateMouseClickDown(point);
return 'Mouse down at: ${point.dx} ${point.dy}';
} else if (isKeyUp) {
await keyPressSimulator.simulateMouseClickUp(point);
return 'Mouse up at: ${point.dx} ${point.dy}';
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
await keyPressSimulator.simulateMouseClickDown(point);
await keyPressSimulator.simulateMouseClickUp(point);
return 'Mouse clicked at: $point';
}
return 'Mouse clicked at: ${point.dx} ${point.dy}';
}
}
// Release all held keys (useful for cleanup)
Future<void> releaseAllHeldKeys() async {
for (final action in _heldKeys.toList()) {
Future<void> releaseAllHeldKeys(List<ZwiftButton> list) async {
for (final action in list) {
final keyPair = supportedApp?.keymap.getKeyPair(action);
if (keyPair?.physicalKey != null) {
await keyPressSimulator.simulateKeyUp(keyPair!.physicalKey);
}
}
_heldKeys.clear();
}
}

View File

@@ -20,10 +20,10 @@ class KeymapExplanation extends StatelessWidget {
final keyboardGroups = availableKeypairs
.filter((e) => e.physicalKey != null)
.groupBy((element) => '${element.physicalKey}-${element.isLongPress}');
.groupBy((element) => '${element.physicalKey?.usbHidUsage}-${element.isLongPress}');
final touchGroups = availableKeypairs
.filter((e) => e.physicalKey == null && e.touchPosition != Offset.zero)
.groupBy((element) => '${element.touchPosition}-${element.isLongPress}');
.groupBy((element) => '${element.touchPosition.dx}-${element.touchPosition.dy}-${element.isLongPress}');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -106,21 +106,23 @@ class KeyWidget extends StatelessWidget {
@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.splitByUpperCase(),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Theme.of(context).colorScheme.onPrimaryContainer,
return IntrinsicWidth(
child: 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.splitByUpperCase(),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
),

View File

@@ -49,39 +49,46 @@ class _LogviewerState extends State<LogViewer> {
@override
Widget build(BuildContext context) {
return SelectionArea(
child: ListView(
physics: const NeverScrollableScrollPhysics(),
controller: _scrollController,
shrinkWrap: true,
reverse: true,
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"],
child: GestureDetector(
onLongPress: () {
setState(() {
_actions = [];
});
},
child: ListView(
physics: const NeverScrollableScrollPhysics(),
controller: _scrollController,
shrinkWrap: true,
reverse: true,
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,
TextSpan(
text: " ${action.entry}",
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
),
)
.toList(),
)
.toList(),
),
),
);
}

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.6.0+6
version: 2.6.3+9
environment:
sdk: ^3.7.0