refactor touch placements: show touches on screen, fix misplaced coordinates - should fix #64

This commit is contained in:
Jonas Bark
2025-09-27 14:48:23 +02:00
parent d0291c68d7
commit 504c71d5c4
9 changed files with 710 additions and 371 deletions

View File

@@ -4,6 +4,7 @@ import 'package:dartx/dartx.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
enum _RideButtonMask {
LEFT_BTN(0x00001),
@@ -72,7 +73,7 @@ class RideNotification extends BaseNotification {
@override
String toString() {
return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name)}';
return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name.splitByUpperCase())}';
}
@override

View File

@@ -8,6 +8,7 @@ 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/testbed.dart';
import 'package:swift_control/widgets/title.dart';
import '../bluetooth/devices/base_device.dart';
@@ -52,120 +53,131 @@ class _DevicePageState extends State<DevicePage> {
onPopInvokedWithResult: (hello, _) {
connection.reset();
},
child: Scaffold(
appBar: AppBar(
title: AppTitle(),
actions: buildMenuButtons(),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
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(
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)
Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flex(
child: Stack(
children: [
Scaffold(
appBar: AppBar(
title: AppTitle(),
actions: buildMenuButtons(),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
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(
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)
Column(
spacing: 12,
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)',
),
),
);
}
},
initialSelection: actionHandler.supportedApp,
hintText: 'Select your Keymap',
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)',
),
),
);
}
},
initialSelection: actionHandler.supportedApp,
hintText: 'Select your Keymap',
),
if (actionHandler.supportedApp != null)
ElevatedButton(
onPressed: () async {
if (actionHandler.supportedApp! is! CustomApp) {
final customApp = CustomApp();
final connectedDevice = connection.devices.firstOrNull;
actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) {
pair.buttons
.filter(
(button) => connectedDevice?.availableButtons.contains(button) == true,
)
.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)
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!);
}
KeymapExplanation(
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
keymap: actionHandler.supportedApp!.keymap,
onUpdate: () {
setState(() {});
controller.text = actionHandler.supportedApp?.name ?? '';
},
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 ?? '';
},
),
],
),
SizedBox(height: 800, child: LogViewer()),
],
SizedBox(height: 800, child: LogViewer()),
],
),
),
),
),
Positioned.fill(child: Testbed()),
],
),
),
);

View File

@@ -8,6 +8,8 @@ import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/testbed.dart';
import 'package:window_manager/window_manager.dart';
import '../bluetooth/messages/click_notification.dart';
@@ -107,142 +109,164 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
});
}
Widget _buildDraggableArea({
List<Widget> _buildDraggableArea({
required Offset position,
required bool enableTouch,
required void Function(Offset newPosition) onPositionChanged,
required Color color,
required KeyPair keyPair,
required String label,
}) {
return Positioned(
left: position.dx,
top: position.dy,
child: PopupMenuButton<PhysicalKeyboardKey>(
tooltip: 'Drag or click for special keys',
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;
final flutterView = WidgetsBinding.instance.platformDispatcher.views.first;
// figure out notch height for e.g. macOS
final differenceInHeight =
(flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio;
if (kDebugMode) {
print('Display Size: ${flutterView.display.size}');
print('View size: ${flutterView.physicalSize}');
print('Difference: $differenceInHeight');
}
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(() {});
},
child: ListTile(
leading: Icon(Icons.music_note_outlined),
trailing: Icon(Icons.arrow_right),
title: Text('Simulate Media key'),
),
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: Container(
color: kDebugMode && false ? Colors.yellow : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Draggable(
feedback: Material(
color: Colors.transparent,
child: _TouchDot(color: Colors.yellow, label: label, keyPair: keyPair),
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'),
),
),
),
childWhenDragging: const SizedBox.shrink(),
onDraggableCanceled: (_, offset) {
setState(() => onPositionChanged(offset));
},
child: _TouchDot(color: color, label: label, keyPair: keyPair),
),
],
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));
},
child: KeypairExplanation(withKey: true, keyPair: keyPair),
),
),
),
);
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)),
],
),
),
];
}
@override
@@ -279,24 +303,26 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
),
),
// Touch Areas
...?actionHandler.supportedApp?.keymap.keyPairs.map(
(keyPair) => _buildDraggableArea(
position: Offset(
keyPair.touchPosition.dx / devicePixelRatio - touchAreaSize / 2,
keyPair.touchPosition.dy / devicePixelRatio - touchAreaSize / 2 - (isDesktop ? touchAreaSize * 1.5 : 0),
),
keyPair: keyPair,
onPositionChanged: (newPos) {
final converted =
newPos.translate(touchAreaSize / 2, touchAreaSize / 2 + (isDesktop ? touchAreaSize * 1.5 : 0)) *
devicePixelRatio;
keyPair.touchPosition = converted;
setState(() {});
},
color: Colors.red,
label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n'),
),
),
...?actionHandler.supportedApp?.keymap.keyPairs
.map(
(keyPair) => _buildDraggableArea(
enableTouch: true,
position: Offset(
keyPair.touchPosition.dx / devicePixelRatio,
keyPair.touchPosition.dy / devicePixelRatio,
),
keyPair: keyPair,
onPositionChanged: (newPos) {
final converted = newPos * devicePixelRatio;
keyPair.touchPosition = converted;
setState(() {});
},
color: Colors.red,
),
)
.flatten(),
Positioned.fill(child: Testbed()),
Positioned(
top: 40,
@@ -304,15 +330,20 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
child: Row(
spacing: 8,
children: [
ElevatedButton.icon(
onPressed: () {
actionHandler.supportedApp?.keymap.reset();
setState(() {});
},
icon: const Icon(Icons.lock_reset),
label: Text('Reset'),
),
ElevatedButton.icon(onPressed: _saveAndClose, icon: const Icon(Icons.save), label: const Text("Save")),
PopupMenuButton(
itemBuilder:
(c) => [
PopupMenuItem(
child: Text('Reset'),
onTap: () {
actionHandler.supportedApp?.keymap.reset();
setState(() {});
},
),
],
icon: Icon(Icons.more_vert),
),
],
),
),
@@ -322,59 +353,46 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
}
}
class _TouchDot extends StatelessWidget {
final Color color;
final String label;
class KeypairExplanation extends StatelessWidget {
final bool withKey;
final KeyPair keyPair;
const _TouchDot({required this.color, required this.label, required this.keyPair});
const KeypairExplanation({super.key, required this.keyPair, this.withKey = false});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
return Row(
spacing: 4,
children: [
Container(
width: touchAreaSize,
height: touchAreaSize,
decoration: BoxDecoration(
color: color.withOpacity(0.6),
shape: BoxShape.circle,
border: Border.all(
color: keyPair.isLongPress ? Colors.green : Colors.black,
width: keyPair.isLongPress ? 3 : 2,
),
if (withKey) KeyWidget(label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n')),
if (keyPair.physicalKey != null) ...[
Icon(switch (keyPair.physicalKey) {
PhysicalKeyboardKey.mediaPlayPause ||
PhysicalKeyboardKey.mediaStop ||
PhysicalKeyboardKey.mediaTrackPrevious ||
PhysicalKeyboardKey.mediaTrackNext ||
PhysicalKeyboardKey.audioVolumeUp ||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
_ => Icons.keyboard,
}, size: 16),
KeyWidget(
label: switch (keyPair.physicalKey) {
PhysicalKeyboardKey.mediaPlayPause => 'Media: Play/Pause',
PhysicalKeyboardKey.mediaStop => 'Media: Stop',
PhysicalKeyboardKey.mediaTrackPrevious => 'Media: Previous',
PhysicalKeyboardKey.mediaTrackNext => 'Media: Next',
PhysicalKeyboardKey.audioVolumeUp => 'Media: Volume Up',
PhysicalKeyboardKey.audioVolumeDown => 'Media: Volume Down',
_ => keyPair.logicalKey?.keyLabel ?? 'Unknown',
},
),
child: Icon(
keyPair.isSpecialKey
? Icons.music_note_outlined
: keyPair.physicalKey != null
? Icons.keyboard_alt_outlined
: Icons.touch_app_outlined,
),
),
if (keyPair.isLongPress) Text('using long press'),
] else ...[
Icon(Icons.touch_app, size: 16),
KeyWidget(label: 'X: ${keyPair.touchPosition.dx.toInt()}, Y: ${keyPair.touchPosition.dy.toInt()}'),
Container(
color: Colors.white.withAlpha(180),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(color: Colors.black, fontSize: 12)),
if (keyPair.physicalKey != null)
Text(switch (keyPair.physicalKey) {
PhysicalKeyboardKey.mediaPlayPause => 'Media: Play/Pause',
PhysicalKeyboardKey.mediaStop => 'Media: Stop',
PhysicalKeyboardKey.mediaTrackPrevious => 'Media: Previous',
PhysicalKeyboardKey.mediaTrackNext => 'Media: Next',
PhysicalKeyboardKey.audioVolumeUp => 'Media: Volume Up',
PhysicalKeyboardKey.audioVolumeDown => 'Media: Volume Down',
_ => keyPair.logicalKey?.keyLabel ?? 'Unknown',
}, style: TextStyle(color: Colors.black87, fontSize: 12)),
if (keyPair.isLongPress)
Text('Long Press', style: TextStyle(color: Colors.green, fontSize: 10, fontWeight: FontWeight.bold)),
],
),
),
if (keyPair.isLongPress) Text('using long press'),
],
],
);
}

View File

@@ -53,7 +53,7 @@ class DesktopActions extends BaseActions {
if (keyPair.physicalKey != null) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
return 'Key pressed: ${keyPair.logicalKey?.keyLabel}';
return 'Key pressed: $keyPair';
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
await keyPressSimulator.simulateMouseClickDown(point);

View File

@@ -32,6 +32,11 @@ class Settings {
}
}
Future<void> reset() async {
await _prefs.clear();
actionHandler.init(null);
}
void setApp(SupportedApp app) {
if (app is CustomApp) {
_prefs.setStringList("customapp", app.encodeKeymap());

View File

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
import '../pages/touch_area.dart';
class KeymapExplanation extends StatelessWidget {
final Keymap keymap;
final VoidCallback onUpdate;
@@ -64,21 +66,11 @@ class KeymapExplanation extends StatelessWidget {
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'),
IntrinsicWidth(child: KeyWidget(label: button.name)),
],
),
),
Padding(padding: const EdgeInsets.all(6), child: KeypairExplanation(keyPair: pair.value.first)),
],
),
],
@@ -93,25 +85,11 @@ class KeymapExplanation extends StatelessWidget {
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'),
KeyWidget(label: button.name),
],
),
),
Padding(padding: const EdgeInsets.all(6), child: KeypairExplanation(keyPair: pair.value.first)),
],
),
],
@@ -122,9 +100,9 @@ class KeymapExplanation extends StatelessWidget {
}
}
class _KeyWidget extends StatelessWidget {
class KeyWidget extends StatelessWidget {
final String label;
const _KeyWidget({super.key, required this.label});
const KeyWidget({super.key, required this.label});
@override
Widget build(BuildContext context) {
@@ -138,7 +116,7 @@ class _KeyWidget extends StatelessWidget {
),
child: Center(
child: Text(
label,
label.splitByUpperCase(),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
@@ -150,7 +128,7 @@ class _KeyWidget extends StatelessWidget {
}
}
extension on String {
extension SplitByUppercase on String {
String splitByUpperCase() {
return replaceAllMapped(RegExp(r'([a-z])([A-Z])'), (match) => '${match.group(1)} ${match.group(2)}').capitalize();
}

View File

@@ -50,42 +50,37 @@ class _LogviewerState extends State<LogViewer> {
Widget build(BuildContext context) {
return SelectionArea(
child: ListView(
physics: const NeverScrollableScrollPhysics(),
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"],
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,
),
),
],
),
),
),
TextButton(
onPressed: () {
_actions.clear();
setState(() {});
},
child: Text('Clear Log'),
),
],
)
.toList(),
),
);
}

View File

@@ -58,23 +58,37 @@ class MenuButton extends StatelessWidget {
itemBuilder:
(c) => [
if (kDebugMode) ...[
...ZwiftButton.values.map(
(e) => PopupMenuItem(
child: Text(e.name),
onTap: () {
Future.delayed(Duration(seconds: 2)).then((_) {
actionHandler.performAction(e);
});
PopupMenuItem(
child: PopupMenuButton(
child: Text("Simulate buttons"),
itemBuilder: (_) {
return ZwiftButton.values
.map(
(e) => PopupMenuItem(
child: Text(e.name),
onTap: () {
Future.delayed(Duration(seconds: 2)).then((_) {
actionHandler.performAction(e);
});
},
),
)
.toList();
},
),
),
PopupMenuItem(child: PopupMenuDivider()),
PopupMenuItem(
child: Text('Continue'),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
},
),
PopupMenuItem(
child: Text('Reset'),
onTap: () async {
await settings.reset();
},
),
PopupMenuItem(child: PopupMenuDivider()),
],
PopupMenuItem(

316
lib/widgets/testbed.dart Normal file
View File

@@ -0,0 +1,316 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
/// A developer overlay that visualizes touches and keyboard events.
/// - Touch dots appear where you touch and fade out over [touchRevealDuration].
/// - Keyboard events are listed temporarily and fade out over [keyboardRevealDuration].
class Testbed extends StatefulWidget {
const Testbed({
super.key,
this.enabled = true,
this.showTouches = true,
this.showKeyboard = true,
this.touchRevealDuration = const Duration(seconds: 2),
this.keyboardRevealDuration = const Duration(seconds: 2),
this.maxKeyboardEvents = 6,
this.touchColor = const Color(0xFF00BCD4), // cyan-ish
this.keyboardBadgeColor = const Color(0xCC000000), // translucent black
this.keyboardTextStyle = const TextStyle(color: Colors.white, fontSize: 12),
});
final bool enabled;
final bool showTouches;
final bool showKeyboard;
final Duration touchRevealDuration;
final Duration keyboardRevealDuration;
final int maxKeyboardEvents;
final Color touchColor;
final Color keyboardBadgeColor;
final TextStyle keyboardTextStyle;
@override
State<Testbed> createState() => _TestbedState();
}
class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
late final Ticker _ticker;
// ----- Touch tracking -----
final Map<int, _TouchSample> _active = <int, _TouchSample>{};
final List<_TouchSample> _history = <_TouchSample>[];
// ----- Keyboard tracking -----
final List<_KeySample> _keys = <_KeySample>[];
// Focus to receive key events without stealing focus from inputs.
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode(debugLabel: 'TestbedFocus', canRequestFocus: true, skipTraversal: true);
_ticker = createTicker((_) {
// Cull expired touch and key samples.
final now = DateTime.now();
_history.removeWhere((s) => now.difference(s.timestamp) > widget.touchRevealDuration);
_keys.removeWhere((k) => now.difference(k.timestamp) > widget.keyboardRevealDuration);
if (mounted) setState(() {});
})..start();
}
@override
void dispose() {
_ticker.dispose();
_focusNode.dispose();
super.dispose();
}
void _onPointerDown(PointerDownEvent e) {
if (!widget.enabled || !widget.showTouches) return;
final sample = _TouchSample(
pointer: e.pointer,
position: e.position,
timestamp: DateTime.now(),
phase: _TouchPhase.down,
);
_active[e.pointer] = sample;
_history.add(sample);
setState(() {});
}
void _onPointerCancel(PointerCancelEvent e) {
if (!widget.enabled || !widget.showTouches || !mounted) return;
_active.remove(e.pointer);
setState(() {});
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (!widget.enabled || !widget.showKeyboard || event is KeyUpEvent) return KeyEventResult.ignored;
final label = event.logicalKey.keyLabel;
final keyName = label.isNotEmpty ? label : event.logicalKey.debugName ?? 'Key';
final isDown = event is KeyDownEvent;
final isUp = event is KeyUpEvent;
// Filter out repeat KeyDowns if desired (optional).
// Here we keep them; comment this block in to drop repeats:
// if (event.repeat) return KeyEventResult.handled;
final sample = _KeySample(
text:
'${isDown
? ""
: isUp
? ""
: ""} $keyName',
timestamp: DateTime.now(),
);
_keys.insert(0, sample);
if (_keys.length > widget.maxKeyboardEvents) {
_keys.removeLast();
}
setState(() {});
// We don't want to prevent normal text input, so we return ignored.
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: _onPointerDown,
onPointerCancel: _onPointerCancel,
behavior: HitTestBehavior.translucent,
child: Focus(
focusNode: _focusNode,
autofocus: true,
canRequestFocus: true,
descendantsAreFocusable: true,
onKeyEvent: _onKey,
child: Stack(
fit: StackFit.passthrough,
children: [
if (widget.showTouches)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _TouchesPainter(
now: DateTime.now(),
samples: _history,
duration: widget.touchRevealDuration,
color: widget.touchColor,
),
),
),
),
if (widget.showKeyboard)
Positioned(
left: 12,
bottom: 12,
child: IgnorePointer(
child: _KeyboardOverlay(
items: _keys,
duration: widget.keyboardRevealDuration,
badgeColor: widget.keyboardBadgeColor,
textStyle: widget.keyboardTextStyle,
),
),
),
],
),
),
);
}
}
// ===== Touches =====
enum _TouchPhase { down, move, up }
class _TouchSample {
_TouchSample({required this.pointer, required this.position, required this.timestamp, required this.phase});
final int pointer;
final Offset position;
final DateTime timestamp;
final _TouchPhase phase;
}
class _TouchesPainter extends CustomPainter {
_TouchesPainter({required this.now, required this.samples, required this.duration, required this.color});
final DateTime now;
final List<_TouchSample> samples;
final Duration duration;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint =
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
for (final s in samples) {
final age = now.difference(s.timestamp);
if (age > duration) continue;
final t = age.inMilliseconds / duration.inMilliseconds.clamp(1, 1 << 30);
final fade = (1.0 - t).clamp(0.0, 1.0);
// Two concentric circles: inner filled pulse + outer ring.
final baseRadius = 22.0;
final pulse = 1.0 + 0.5 * math.sin(t * math.pi); // subtle pulsing
final rOuter = baseRadius * (1.0 + 0.35 * t);
final rInner = baseRadius * 0.5 * pulse;
// Outer ring (stroke, fading)
paint
..style = PaintingStyle.stroke
..color = color.withOpacity(0.35 * fade);
canvas.drawCircle(s.position, rOuter, paint);
// Inner fill (stronger)
final fill =
Paint()
..style = PaintingStyle.fill
..color = color.withOpacity(0.35 + 0.35 * fade);
canvas.drawCircle(s.position, rInner, fill);
// Tiny center dot for precision
final center =
Paint()
..style = PaintingStyle.fill
..color = color.withOpacity(0.9 * fade);
canvas.drawCircle(s.position, 2.5, center);
}
}
@override
bool shouldRepaint(covariant _TouchesPainter oldDelegate) {
return oldDelegate.now != now ||
oldDelegate.samples != samples ||
oldDelegate.duration != duration ||
oldDelegate.color != color;
}
}
// ===== Keyboard overlay =====
class _KeySample {
_KeySample({required this.text, required this.timestamp});
final String text;
final DateTime timestamp;
}
class _KeyboardOverlay extends StatelessWidget {
const _KeyboardOverlay({
super.key,
required this.items,
required this.duration,
required this.badgeColor,
required this.textStyle,
});
final List<_KeySample> items;
final Duration duration;
final Color badgeColor;
final TextStyle textStyle;
@override
Widget build(BuildContext context) {
final now = DateTime.now();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final item in items)
_KeyboardToast(
text: item.text,
age: now.difference(item.timestamp),
duration: duration,
badgeColor: badgeColor,
textStyle: textStyle,
),
],
);
}
}
class _KeyboardToast extends StatelessWidget {
const _KeyboardToast({
required this.text,
required this.age,
required this.duration,
required this.badgeColor,
required this.textStyle,
});
final String text;
final Duration age;
final Duration duration;
final Color badgeColor;
final TextStyle textStyle;
@override
Widget build(BuildContext context) {
final t = (age.inMilliseconds / duration.inMilliseconds.clamp(1, 1 << 30)).clamp(0.0, 1.0);
final fade = 1.0 - t;
return Opacity(
opacity: fade,
child: Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(color: badgeColor, borderRadius: BorderRadius.circular(12)),
child: Text(text, style: textStyle),
),
);
}
}