mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f51d588510 | ||
|
|
54b2f73384 | ||
|
|
dc63f693f0 | ||
|
|
455db754d8 | ||
|
|
cbef8fc044 | ||
|
|
d8e45f849a | ||
|
|
f83defb37b | ||
|
|
5c8db11536 | ||
|
|
30aa5b33a3 | ||
|
|
ca41e69a17 |
0
launch.json → .vscode/launch.json
vendored
0
launch.json → .vscode/launch.json
vendored
@@ -1,3 +1,9 @@
|
||||
### 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"
|
||||
- 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
|
||||
- show firmware version of connected device
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -280,14 +280,20 @@ abstract class BaseDevice {
|
||||
|
||||
// Handle release events for long press keys
|
||||
final buttonsReleased = _previouslyPressedButtons.toList();
|
||||
if (buttonsReleased.isNotEmpty) {
|
||||
final isLongPress =
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
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) {
|
||||
final wasLongPress =
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && wasLongPress) {
|
||||
await _performRelease(buttonsReleased);
|
||||
}
|
||||
|
||||
@@ -300,29 +306,42 @@ abstract class BaseDevice {
|
||||
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 {
|
||||
_performActions(buttonsClicked, true);
|
||||
_longPressTimer = Timer.periodic(const Duration(milliseconds: 350), (timer) async {
|
||||
_performClick(buttonsClicked);
|
||||
});
|
||||
} else if (isLongPress) {
|
||||
// Update currently pressed buttons
|
||||
_previouslyPressedButtons = buttonsClicked.toSet();
|
||||
}
|
||||
// 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -349,11 +368,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
);*/
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,154 +131,179 @@ 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) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -371,8 +399,9 @@ class KeypairExplanation extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
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) ...[
|
||||
|
||||
@@ -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 && isKeyUp
|
||||
? "click"
|
||||
: isKeyDown
|
||||
? "down"
|
||||
: "up"}";
|
||||
}
|
||||
return "No touch performed";
|
||||
}
|
||||
|
||||
@@ -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 && 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 {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Key released: $keyPair';
|
||||
}
|
||||
} else {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
if (isKeyDown && isKeyUp) {
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Mouse clicked at: $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 {
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Mouse up 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.4+10
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
|
||||
Reference in New Issue
Block a user