Compare commits

..

2 Commits

Author SHA1 Message Date
Jonas Bark
f51d588510 improve UI when handling custom keymaps around the edges of the screen 2025-10-02 21:38:56 +02:00
Jonas Bark
54b2f73384 fix delayed clicks in some apps 2025-10-02 21:05:15 +02:00
8 changed files with 224 additions and 158 deletions

View File

@@ -2,6 +2,7 @@
- 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"
- improve UI when handling custom keymaps around the edges of the screen
### 2.6.0 (2025-09-30)
- refactor touch placements: show touches on screen, fix misplaced coordinates - should fix #64

View File

@@ -56,7 +56,7 @@ class AccessibilityService : AccessibilityService(), Listener {
path.moveTo(x.toFloat(), y.toFloat())
path.lineTo(x.toFloat()+1, y.toFloat())
val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown)
val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown && !isKeyUp)
gestureBuilder.addStroke(stroke)
dispatchGesture(gestureBuilder.build(), null, null)

View File

@@ -272,9 +272,6 @@ 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) {
@@ -283,6 +280,9 @@ abstract class BaseDevice {
// Handle release events for long press keys
final buttonsReleased = _previouslyPressedButtons.toList();
final isLongPress =
buttonsReleased.singleOrNull != null &&
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
if (buttonsReleased.isNotEmpty && isLongPress) {
await _performRelease(buttonsReleased);
}
@@ -290,37 +290,58 @@ abstract class BaseDevice {
} else {
// Handle release events for buttons that are no longer pressed
final buttonsReleased = _previouslyPressedButtons.difference(buttonsClicked.toSet()).toList();
if (buttonsReleased.isNotEmpty && isLongPress) {
final wasLongPress =
buttonsReleased.singleOrNull != null &&
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
if (buttonsReleased.isNotEmpty && wasLongPress) {
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: 350), (timer) async {
_performActions(buttonsClicked, true);
_performClick(buttonsClicked);
});
}
// Update currently pressed buttons
_previouslyPressedButtons = buttonsClicked.toSet();
return _performActions(buttonsClicked, false);
if (isLongPress) {
return _performDown(buttonsClicked);
} else {
return _performClick(buttonsClicked);
}
}
}
Future<void> _performActions(List<ZwiftButton> buttonsClicked, bool repeated) async {
if (!repeated &&
buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
Future<void> _performDown(List<ZwiftButton> buttonsClicked) async {
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
settings.getVibrationEnabled()) {
await _vibrate();
}
for (final action in buttonsClicked) {
// For repeated actions, don't trigger key down/up events (useful for long press)
final isKeyDown = !repeated;
actionStreamInternal.add(
LogNotification(await actionHandler.performAction(action, isKeyDown: isKeyDown, isKeyUp: false)),
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: false)),
);
}
}
Future<void> _performClick(List<ZwiftButton> buttonsClicked) async {
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
settings.getVibrationEnabled()) {
await _vibrate();
}
for (final action in buttonsClicked) {
actionStreamInternal.add(
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: true)),
);
}
}

View File

@@ -137,150 +137,173 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio
: 0.0;
if (kDebugMode) {
if (kDebugMode && false) {
print('Position: $position');
print('Display Size: ${flutterView.display.size}');
print('View size: ${flutterView.physicalSize}');
print('Difference: $differenceInHeight');
}
final isOnTheRightEdge = position.dx > (MediaQuery.sizeOf(context).width - 250);
final label = KeypairExplanation(withKey: true, keyPair: keyPair);
final iconSize = 40.0;
final icon = Container(
decoration: BoxDecoration(
color: color.withOpacity(0.4),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
(!keyPair.isSpecialKey && keyPair.physicalKey == null && keyPair.touchPosition != Offset.zero)
? Icons.add
: Icons.drag_indicator_outlined,
size: iconSize,
shadows: [
Shadow(color: Colors.white, offset: Offset(1, 1)),
Shadow(color: Colors.white, offset: Offset(-1, -1)),
Shadow(color: Colors.white, offset: Offset(-1, 1)),
Shadow(color: Colors.white, offset: Offset(-1, 1)),
Shadow(color: Colors.white, offset: Offset(1, -1)),
],
),
);
return [
Positioned(
left: position.dx,
top: position.dy - differenceInHeight,
child: PopupMenuButton<PhysicalKeyboardKey>(
enabled: enableTouch,
tooltip: 'Drag to reposition. Tap to edit.',
itemBuilder:
(context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(
leading: Icon(Icons.keyboard_alt_outlined),
title: const Text('Simulate Keyboard shortcut'),
),
onTap: () async {
await showDialog<void>(
context: context,
barrierDismissible: false, // enable Escape key
builder:
(c) => HotKeyListenerDialog(
customApp: actionHandler.supportedApp! as CustomApp,
keyPair: keyPair,
),
);
setState(() {});
},
),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)),
onTap: () {
keyPair.physicalKey = null;
keyPair.logicalKey = null;
setState(() {});
},
),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
onTap: () {
keyPair.isLongPress = !keyPair.isLongPress;
setState(() {});
},
child: CheckboxListTile(
value: keyPair.isLongPress,
onChanged: (value) {
keyPair.isLongPress = value ?? false;
setState(() {});
Navigator.of(context).pop();
},
title: const Text('Long Press Mode (vs. repeating)'),
),
),
PopupMenuDivider(),
PopupMenuItem(
child: PopupMenuButton<PhysicalKeyboardKey>(
padding: EdgeInsets.zero,
itemBuilder:
(context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaPlayPause,
child: const Text('Media: Play/Pause'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaStop,
child: const Text('Media: Stop'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackPrevious,
child: const Text('Media: Previous'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackNext,
child: const Text('Media: Next'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeUp,
child: const Text('Media: Volume Up'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeDown,
child: const Text('Media: Volume Down'),
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
setState(() {});
},
child: FractionalTranslation(
translation: Offset(isOnTheRightEdge ? -1.0 : 0.0, 0),
child: PopupMenuButton<PhysicalKeyboardKey>(
enabled: enableTouch,
tooltip: 'Drag to reposition. Tap to edit.',
itemBuilder:
(context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(
leading: Icon(Icons.music_note_outlined),
trailing: Icon(Icons.arrow_right),
title: Text('Simulate Media key'),
leading: Icon(Icons.keyboard_alt_outlined),
title: const Text('Simulate Keyboard shortcut'),
),
onTap: () async {
await showDialog<void>(
context: context,
barrierDismissible: false, // enable Escape key
builder:
(c) => HotKeyListenerDialog(
customApp: actionHandler.supportedApp! as CustomApp,
keyPair: keyPair,
),
);
setState(() {});
},
),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)),
onTap: () {
keyPair.physicalKey = null;
keyPair.logicalKey = null;
setState(() {});
},
),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
onTap: () {
keyPair.isLongPress = !keyPair.isLongPress;
setState(() {});
},
child: CheckboxListTile(
value: keyPair.isLongPress,
onChanged: (value) {
keyPair.isLongPress = value ?? false;
setState(() {});
Navigator.of(context).pop();
},
title: const Text('Long Press Mode (vs. repeating)'),
),
),
),
PopupMenuDivider(),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(title: const Text('Delete Keymap'), leading: Icon(Icons.delete, color: Colors.red)),
onTap: () {
actionHandler.supportedApp!.keymap.keyPairs.remove(keyPair);
setState(() {});
},
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
setState(() {});
},
child: Draggable(
feedback: Material(color: Colors.transparent, child: KeypairExplanation(withKey: true, keyPair: keyPair)),
childWhenDragging: const SizedBox.shrink(),
onDraggableCanceled: (_, offset) {
final fixedPosition = offset + Offset(0, differenceInHeight);
setState(() => onPositionChanged(fixedPosition));
PopupMenuDivider(),
PopupMenuItem(
child: PopupMenuButton<PhysicalKeyboardKey>(
padding: EdgeInsets.zero,
itemBuilder:
(context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaPlayPause,
child: const Text('Media: Play/Pause'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaStop,
child: const Text('Media: Stop'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackPrevious,
child: const Text('Media: Previous'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackNext,
child: const Text('Media: Next'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeUp,
child: const Text('Media: Volume Up'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeDown,
child: const Text('Media: Volume Down'),
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
setState(() {});
},
child: ListTile(
leading: Icon(Icons.music_note_outlined),
trailing: Icon(Icons.arrow_right),
title: Text('Simulate Media key'),
),
),
),
PopupMenuDivider(),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(title: const Text('Delete Keymap'), leading: Icon(Icons.delete, color: Colors.red)),
onTap: () {
actionHandler.supportedApp!.keymap.keyPairs.remove(keyPair);
setState(() {});
},
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
setState(() {});
},
child: KeypairExplanation(withKey: true, keyPair: keyPair),
child: label,
),
),
),
if (!keyPair.isSpecialKey && keyPair.physicalKey == null && keyPair.touchPosition != Offset.zero)
Positioned(
left: position.dx - 10,
top: position.dy - 10 - differenceInHeight,
child: Icon(
Icons.add,
size: 20,
shadows: [
Shadow(color: Colors.white, offset: Offset(1, 1)),
Shadow(color: Colors.white, offset: Offset(-1, -1)),
Shadow(color: Colors.white, offset: Offset(-1, 1)),
Shadow(color: Colors.white, offset: Offset(-1, 1)),
Shadow(color: Colors.white, offset: Offset(1, -1)),
],
),
Positioned(
left: position.dx - iconSize / 2,
top: position.dy - differenceInHeight - iconSize / 2,
child: Draggable(
feedback: Material(color: Colors.transparent, child: icon),
childWhenDragging: const SizedBox.shrink(),
onDraggableCanceled: (velo, offset) {
// otherwise simulated touch will move it
if (velo.pixelsPerSecond.distance > 0) {
final fixedPosition = offset + Offset(iconSize / 2, differenceInHeight + iconSize / 2);
setState(() => onPositionChanged(fixedPosition));
}
},
child: icon,
),
),
];
}
@@ -378,6 +401,7 @@ class KeypairExplanation extends StatelessWidget {
Widget build(BuildContext context) {
return Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (withKey) KeyWidget(label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n')),
if (keyPair.physicalKey != null) ...[

View File

@@ -44,11 +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()} -> ${isKeyDown
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp
? "click"
: isKeyDown
? "down"
: isKeyUp
? "up"
: "click"}";
: "up"}";
}
return "No touch performed";
}

View File

@@ -19,30 +19,30 @@ class DesktopActions extends BaseActions {
// 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 {
if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
return 'Key clicked: $keyPair';
} else if (isKeyDown) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
return 'Key pressed: $keyPair';
} else {
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
return 'Key released: $keyPair';
}
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
if (isKeyDown) {
if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateMouseClickDown(point);
await keyPressSimulator.simulateMouseClickUp(point);
return 'Mouse clicked at: ${point.dx} ${point.dy}';
} else if (isKeyDown) {
await keyPressSimulator.simulateMouseClickDown(point);
return 'Mouse down at: ${point.dx} ${point.dy}';
} else if (isKeyUp) {
} else {
await keyPressSimulator.simulateMouseClickUp(point);
return 'Mouse up at: ${point.dx} ${point.dy}';
} else {
await keyPressSimulator.simulateMouseClickDown(point);
await keyPressSimulator.simulateMouseClickUp(point);
}
return 'Mouse clicked at: ${point.dx} ${point.dy}';
}
}

View File

@@ -90,6 +90,23 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
setState(() {});
}
void _onPointerUp(PointerUpEvent e) {
if (!widget.enabled ||
!widget.showTouches ||
(e.kind != PointerDeviceKind.unknown && e.kind != PointerDeviceKind.mouse)) {
return;
}
final sample = _TouchSample(
pointer: e.pointer,
position: e.position,
timestamp: DateTime.now(),
phase: _TouchPhase.up,
);
_active[e.pointer] = sample;
_history.add(sample);
setState(() {});
}
void _onPointerCancel(PointerCancelEvent e) {
if (!widget.enabled || !widget.showTouches || !mounted) return;
_active.remove(e.pointer);
@@ -130,6 +147,7 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
Widget build(BuildContext context) {
return Listener(
onPointerDown: _onPointerDown,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
behavior: HitTestBehavior.translucent,
child: Focus(
@@ -206,6 +224,8 @@ class _TouchesPainter extends CustomPainter {
final age = now.difference(s.timestamp);
if (age > duration) continue;
final color = s.phase == _TouchPhase.down ? this.color : Colors.red;
final t = age.inMilliseconds / duration.inMilliseconds.clamp(1, 1 << 30);
final fade = (1.0 - t).clamp(0.0, 1.0);

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.3+9
version: 2.6.4+10
environment:
sdk: ^3.7.0