diff --git a/lib/bluetooth/messages/ride_notification.dart b/lib/bluetooth/messages/ride_notification.dart index 89e08d8..2b07250 100644 --- a/lib/bluetooth/messages/ride_notification.dart +++ b/lib/bluetooth/messages/ride_notification.dart @@ -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 diff --git a/lib/pages/device.dart b/lib/pages/device.dart index cd457ff..b7b74c0 100644 --- a/lib/pages/device.dart +++ b/lib/pages/device.dart @@ -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 { 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( - controller: controller, - dropdownMenuEntries: - SupportedApp.supportedApps - .map((app) => DropdownMenuEntry(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( + controller: controller, + dropdownMenuEntries: + SupportedApp.supportedApps + .map((app) => DropdownMenuEntry(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(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(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()), + ], ), ), ); diff --git a/lib/pages/touch_area.dart b/lib/pages/touch_area.dart index ba9d944..bb106f8 100644 --- a/lib/pages/touch_area.dart +++ b/lib/pages/touch_area.dart @@ -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 { }); } - Widget _buildDraggableArea({ + List _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( - tooltip: 'Drag or click for special keys', - itemBuilder: - (context) => [ - PopupMenuItem( - value: null, - child: ListTile( - leading: Icon(Icons.keyboard_alt_outlined), - title: const Text('Simulate Keyboard shortcut'), - ), - onTap: () async { - await showDialog( - context: context, - barrierDismissible: false, // enable Escape key - builder: - (c) => - HotKeyListenerDialog(customApp: actionHandler.supportedApp! as CustomApp, keyPair: keyPair), - ); - setState(() {}); - }, - ), - PopupMenuItem( - value: null, - child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)), - onTap: () { - keyPair.physicalKey = null; - keyPair.logicalKey = null; - setState(() {}); - }, - ), - PopupMenuItem( - 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( - padding: EdgeInsets.zero, - itemBuilder: - (context) => [ - PopupMenuItem( - value: PhysicalKeyboardKey.mediaPlayPause, - child: const Text('Media: Play/Pause'), - ), - PopupMenuItem( - value: PhysicalKeyboardKey.mediaStop, - child: const Text('Media: Stop'), - ), - PopupMenuItem( - value: PhysicalKeyboardKey.mediaTrackPrevious, - child: const Text('Media: Previous'), - ), - PopupMenuItem( - value: PhysicalKeyboardKey.mediaTrackNext, - child: const Text('Media: Next'), - ), - PopupMenuItem( - value: PhysicalKeyboardKey.audioVolumeUp, - child: const Text('Media: Volume Up'), - ), - PopupMenuItem( - 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( + enabled: enableTouch, + tooltip: 'Drag to reposition. Tap to edit.', + itemBuilder: + (context) => [ + PopupMenuItem( + value: null, + child: ListTile( + leading: Icon(Icons.keyboard_alt_outlined), + title: const Text('Simulate Keyboard shortcut'), + ), + onTap: () async { + await showDialog( + 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( + value: null, + child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)), + onTap: () { + keyPair.physicalKey = null; + keyPair.logicalKey = null; + setState(() {}); + }, + ), + PopupMenuItem( + 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( - 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( + padding: EdgeInsets.zero, + itemBuilder: + (context) => [ + PopupMenuItem( + value: PhysicalKeyboardKey.mediaPlayPause, + child: const Text('Media: Play/Pause'), + ), + PopupMenuItem( + value: PhysicalKeyboardKey.mediaStop, + child: const Text('Media: Stop'), + ), + PopupMenuItem( + value: PhysicalKeyboardKey.mediaTrackPrevious, + child: const Text('Media: Previous'), + ), + PopupMenuItem( + value: PhysicalKeyboardKey.mediaTrackNext, + child: const Text('Media: Next'), + ), + PopupMenuItem( + value: PhysicalKeyboardKey.audioVolumeUp, + child: const Text('Media: Volume Up'), + ), + PopupMenuItem( + 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( + 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 { ), ), // 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 { 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 { } } -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'), + ], ], ); } diff --git a/lib/utils/actions/desktop.dart b/lib/utils/actions/desktop.dart index 4042204..ae275f3 100644 --- a/lib/utils/actions/desktop.dart +++ b/lib/utils/actions/desktop.dart @@ -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); diff --git a/lib/utils/settings/settings.dart b/lib/utils/settings/settings.dart index 3211c81..46e42a7 100644 --- a/lib/utils/settings/settings.dart +++ b/lib/utils/settings/settings.dart @@ -32,6 +32,11 @@ class Settings { } } + Future reset() async { + await _prefs.clear(); + actionHandler.init(null); + } + void setApp(SupportedApp app) { if (app is CustomApp) { _prefs.setStringList("customapp", app.encodeKeymap()); diff --git a/lib/widgets/keymap_explanation.dart b/lib/widgets/keymap_explanation.dart index 350256d..3d7ead5 100644 --- a/lib/widgets/keymap_explanation.dart +++ b/lib/widgets/keymap_explanation.dart @@ -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(); } diff --git a/lib/widgets/logviewer.dart b/lib/widgets/logviewer.dart index 1a280d3..4eb6bf1 100644 --- a/lib/widgets/logviewer.dart +++ b/lib/widgets/logviewer.dart @@ -50,42 +50,37 @@ class _LogviewerState extends State { 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: ["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: ["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(), ), ); } diff --git a/lib/widgets/menu.dart b/lib/widgets/menu.dart index 4e9361a..d157d6e 100644 --- a/lib/widgets/menu.dart +++ b/lib/widgets/menu.dart @@ -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( diff --git a/lib/widgets/testbed.dart b/lib/widgets/testbed.dart new file mode 100644 index 0000000..013441a --- /dev/null +++ b/lib/widgets/testbed.dart @@ -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 createState() => _TestbedState(); +} + +class _TestbedState extends State with SingleTickerProviderStateMixin { + late final Ticker _ticker; + + // ----- Touch tracking ----- + final Map _active = {}; + 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), + ), + ); + } +}