make the UI look nicer

This commit is contained in:
Jonas Bark
2025-10-10 14:23:14 +02:00
parent a469134d2f
commit 9ac73ec6fc
18 changed files with 706 additions and 545 deletions

View File

@@ -5,7 +5,6 @@ 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/bluetooth/protocol/zwift.pb.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:universal_ble/universal_ble.dart';
@@ -90,9 +89,7 @@ class ZwiftRide extends BaseDevice {
'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);
}
sendCommand(Opcode.RESET, null);
}
switch (opcode) {
@@ -250,5 +247,4 @@ class ZwiftRide extends BaseDevice {
withoutResponse: true,
);
}
}

View File

@@ -6,7 +6,9 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
import 'package:swift_control/bluetooth/protocol/zp.pb.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/markdown.dart';
import 'package:swift_control/pages/touch_area.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/loading_widget.dart';
@@ -106,6 +108,10 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
final canVibrate = connection.devices.any(
(device) => (device.device.name == 'Zwift Ride' || device.device.name == 'Zwift Play') && device.isConnected,
);
return ScaffoldMessenger(
key: _snackBarMessengerKey,
child: PopScope(
@@ -121,249 +127,319 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.only(top: 16, left: 8.0, right: 8, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 10,
children: [
Text('Connected Devices:', style: Theme.of(context).textTheme.titleMedium),
if (connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('Connected Devices', style: Theme.of(context).textTheme.titleMedium),
),
Card(
child: Padding(
padding: EdgeInsets.only(
left: 16.0,
right: 16,
top: 16,
bottom: actionHandler is RemoteActions ? 0 : 12,
),
padding: const EdgeInsets.all(8),
child: Text(
'''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.
child: Column(
children: [
if (connection.devices.isEmpty)
Text('No devices connected. Go back and connect a device to get started.'),
...connection.devices.map(
(device) => Row(
children: [
Text(
device.device.name?.screenshot ?? device.runtimeType.toString(),
style: TextStyle(fontWeight: FontWeight.bold),
),
if (device.batteryLevel != null) ...[
Icon(switch (device.batteryLevel!) {
>= 80 => Icons.battery_full,
>= 60 => Icons.battery_6_bar,
>= 50 => Icons.battery_5_bar,
>= 25 => Icons.battery_4_bar,
>= 10 => Icons.battery_2_bar,
_ => Icons.battery_alert,
}),
Text('${device.batteryLevel}%'),
if (device.firmwareVersion != null) Text(' - Firmware: ${device.firmwareVersion}'),
],
],
),
),
if (actionHandler is RemoteActions)
Row(
children: [
Text(
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected'}',
),
LoadingWidget(
futureCallback: () async {
final requirement = RemoteRequirement();
await requirement.reconnect();
},
renderChild: (isLoading, tap) => TextButton(
onPressed: tap,
child: isLoading ? SmallProgressIndicator() : Text('Reconnect'),
),
),
],
),
if (connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
Container(
margin: EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
'''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. 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''',
),
Row(
children: [
TextButton(
onPressed: () {
connection.devices.whereType<ZwiftClickV2>().forEach(
(device) => device.sendCommand(Opcode.RESET, null),
);
},
child: Text('Reset now'),
),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
),
);
},
child: Text('Troubleshooting'),
),
],
),
],
),
),
],
),
),
Text(
connection.devices.joinToString(
separator: '\n',
transform: (it) {
return """${it.device.name?.screenshot ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}
${it.batteryLevel != null ? ' - Battery Level: ${it.batteryLevel}%' : ''}
${it.firmwareVersion != null ? ' - Firmware Version: ${it.firmwareVersion}' : ''}"""
.trim();
},
),
),
if (actionHandler is RemoteActions)
Row(
children: [
Text(
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected'}',
),
LoadingWidget(
futureCallback: () async {
final requirement = RemoteRequirement();
await requirement.reconnect();
},
renderChild: (isLoading, tap) => TextButton(
onPressed: tap,
child: isLoading ? SmallProgressIndicator() : Text('Reconnect'),
),
),
],
if (!kIsWeb) ...[
SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('Customize', style: Theme.of(context).textTheme.titleMedium),
),
Divider(color: Theme.of(context).colorScheme.primary, height: 30),
if (!kIsWeb)
Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flex(
Card(
child: Padding(
padding: EdgeInsets.only(
left: 16.0,
right: 16,
top: 16,
bottom: canVibrate ? 0 : 12,
),
child: Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: MediaQuery.sizeOf(context).width > 600
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
direction: MediaQuery.sizeOf(context).width > 600 ? Axis.horizontal : Axis.vertical,
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownMenu<SupportedApp?>(
controller: controller,
dropdownMenuEntries: [
..._getAllApps().map(
(app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name),
),
DropdownMenuEntry(
value: CustomApp(profileName: 'New'),
label: 'Create new keymap',
labelWidget: Text('Create new keymap'),
leadingIcon: Icon(Icons.add),
),
],
label: Text('Select Keymap / app'),
onSelected: (app) async {
if (app == null) {
return;
} else if (app.name == 'New') {
final profileName = await _showNewProfileDialog();
if (profileName != null && profileName.isNotEmpty) {
final customApp = CustomApp(profileName: profileName);
actionHandler.supportedApp = customApp;
await settings.setApp(customApp);
controller.text = profileName;
setState(() {});
}
} else {
controller.text = app.name ?? '';
actionHandler.supportedApp = app;
await 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',
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
spacing: 8,
children: [
if (actionHandler.supportedApp != null)
ElevatedButton.icon(
onPressed: () async {
if (actionHandler.supportedApp is! CustomApp) {
await _duplicate(actionHandler.supportedApp!.name);
}
final result = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
if (result == true && actionHandler.supportedApp is CustomApp) {
await settings.setApp(actionHandler.supportedApp!);
}
setState(() {});
},
icon: Icon(Icons.edit),
label: Text('Customize'),
),
IconButton(
onPressed: () async {
final currentProfile = actionHandler.supportedApp?.name;
final action = await _showManageProfileDialog(currentProfile);
if (action != null) {
if (action == 'rename') {
final newName = await _showRenameProfileDialog(currentProfile!);
if (newName != null && newName.isNotEmpty && newName != currentProfile) {
await settings.duplicateCustomAppProfile(currentProfile, newName);
await settings.deleteCustomAppProfile(currentProfile);
final customApp = CustomApp(profileName: newName);
final savedKeymap = settings.getCustomAppKeymap(newName);
if (savedKeymap != null) {
customApp.decodeKeymap(savedKeymap);
}
Expanded(
child: DropdownMenu<SupportedApp?>(
controller: controller,
dropdownMenuEntries: [
..._getAllApps().map(
(app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name),
),
DropdownMenuEntry(
value: CustomApp(profileName: 'New'),
label: 'Create new keymap',
labelWidget: Text('Create new keymap'),
leadingIcon: Icon(Icons.add),
),
],
label: Text('Select Keymap / app'),
onSelected: (app) async {
if (app == null) {
return;
} else if (app.name == 'New') {
final profileName = await _showNewProfileDialog();
if (profileName != null && profileName.isNotEmpty) {
final customApp = CustomApp(profileName: profileName);
actionHandler.supportedApp = customApp;
await settings.setApp(customApp);
controller.text = newName;
controller.text = profileName;
setState(() {});
}
} else if (action == 'duplicate') {
_duplicate(currentProfile!);
} else if (action == 'delete') {
final confirmed = await _showDeleteConfirmDialog(currentProfile!);
if (confirmed == true) {
await settings.deleteCustomAppProfile(currentProfile);
controller.text = '';
setState(() {});
} else {
controller.text = app.name ?? '';
actionHandler.supportedApp = app;
await 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)',
),
),
);
}
} else if (action == 'import') {
final jsonData = await _showImportDialog();
if (jsonData != null && jsonData.isNotEmpty) {
final success = await settings.importCustomAppProfile(jsonData);
if (mounted) {
if (success) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Profile imported successfully'),
duration: Duration(seconds: 5),
),
);
}
},
initialSelection: actionHandler.supportedApp,
hintText: 'Select your Keymap',
),
),
Row(
children: [
if (actionHandler.supportedApp != null)
ElevatedButton.icon(
onPressed: () async {
if (actionHandler.supportedApp is! CustomApp) {
await _duplicate(actionHandler.supportedApp!.name);
}
final result = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
if (result == true && actionHandler.supportedApp is CustomApp) {
await settings.setApp(actionHandler.supportedApp!);
}
setState(() {});
},
icon: Icon(Icons.edit),
label: Text('Edit'),
),
IconButton(
onPressed: () async {
final currentProfile = actionHandler.supportedApp?.name;
final action = await _showManageProfileDialog(currentProfile);
if (action != null) {
if (action == 'rename') {
final newName = await _showRenameProfileDialog(currentProfile!);
if (newName != null && newName.isNotEmpty && newName != currentProfile) {
await settings.duplicateCustomAppProfile(currentProfile, newName);
await settings.deleteCustomAppProfile(currentProfile);
final customApp = CustomApp(profileName: newName);
final savedKeymap = settings.getCustomAppKeymap(newName);
if (savedKeymap != null) {
customApp.decodeKeymap(savedKeymap);
}
actionHandler.supportedApp = customApp;
await settings.setApp(customApp);
controller.text = newName;
setState(() {});
} else {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Failed to import profile. Invalid format.'),
duration: Duration(seconds: 5),
backgroundColor: Colors.red,
),
);
}
} else if (action == 'duplicate') {
_duplicate(currentProfile!);
} else if (action == 'delete') {
final confirmed = await _showDeleteConfirmDialog(currentProfile!);
if (confirmed == true) {
await settings.deleteCustomAppProfile(currentProfile);
controller.text = '';
setState(() {});
}
} else if (action == 'import') {
final jsonData = await _showImportDialog();
if (jsonData != null && jsonData.isNotEmpty) {
final success = await settings.importCustomAppProfile(jsonData);
if (mounted) {
if (success) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Profile imported successfully'),
duration: Duration(seconds: 5),
),
);
setState(() {});
} else {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Failed to import profile. Invalid format.'),
duration: Duration(seconds: 5),
backgroundColor: Colors.red,
),
);
}
}
}
} else if (action == 'export') {
final currentProfile =
(actionHandler.supportedApp as CustomApp).profileName;
final jsonData = settings.exportCustomAppProfile(currentProfile);
if (jsonData != null) {
await Clipboard.setData(ClipboardData(text: jsonData));
if (mounted) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Profile "$currentProfile" exported to clipboard'),
duration: Duration(seconds: 5),
),
);
}
}
}
}
} else if (action == 'export') {
final currentProfile = (actionHandler.supportedApp as CustomApp).profileName;
final jsonData = settings.exportCustomAppProfile(currentProfile);
if (jsonData != null) {
await Clipboard.setData(ClipboardData(text: jsonData));
if (mounted) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Profile "$currentProfile" exported to clipboard'),
duration: Duration(seconds: 5),
),
);
}
}
}
}
},
icon: Icon(Icons.more_vert),
},
icon: Icon(Icons.more_vert),
),
],
),
],
),
if (actionHandler.supportedApp != null)
KeymapExplanation(
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
keymap: actionHandler.supportedApp!.keymap,
onUpdate: () {
setState(() {});
controller.text = actionHandler.supportedApp?.name ?? '';
},
),
if (canVibrate) ...[
SwitchListTile(
title: Text('Enable vibration feedback when shifting gears'),
value: settings.getVibrationEnabled(),
contentPadding: EdgeInsets.zero,
onChanged: (value) async {
await settings.setVibrationEnabled(value);
setState(() {});
},
),
],
],
),
if (actionHandler.supportedApp != null)
KeymapExplanation(
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
keymap: actionHandler.supportedApp!.keymap,
onUpdate: () {
setState(() {});
controller.text = actionHandler.supportedApp?.name ?? '';
},
),
if (connection.devices.any(
(device) =>
(device.device.name == 'Zwift Ride' || device.device.name == 'Zwift Play') &&
device.isConnected,
))
SwitchListTile(
title: Text('Vibration on Shift'),
subtitle: Text('Enable vibration feedback when shifting gears'),
value: settings.getVibrationEnabled(),
onChanged: (value) async {
await settings.setVibrationEnabled(value);
setState(() {});
},
),
if (kDebugMode &&
connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
ElevatedButton(
onPressed: () {
(connection.devices.first as ZwiftClickV2).test();
},
child: Text('Test'),
),
],
),
),
],
SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('Logs', style: Theme.of(context).textTheme.titleMedium),
),
LogViewer(),
],
),

View File

@@ -92,53 +92,47 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: buildMenuButtons(),
),
body:
_requirements.isEmpty
? Center(child: CircularProgressIndicator())
: Column(
spacing: 8,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0),
child: Text(
'Please complete the following requirements to make the app work correctly:',
style: Theme.of(context).textTheme.titleMedium,
),
),
SwitchListTile.adaptive(
value: _local,
title: Text('Trainer app is running on this device'),
subtitle: Text('Turn off if you want to control another device, e.g. your tablet'),
onChanged: (local) {
if (kIsWeb || Platform.isIOS) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('This platform only supports controlling trainer apps on other devices'),
),
);
} else {
initializeActions(local);
setState(() {
_local = local;
_reloadRequirements();
});
}
},
),
Expanded(
body: _requirements.isEmpty
? Center(child: CircularProgressIndicator())
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
SwitchListTile.adaptive(
value: _local,
title: Text('Trainer app is running on this device'),
subtitle: Text('Turn off if you want to control another device, e.g. your tablet'),
onChanged: (local) {
if (kIsWeb || Platform.isIOS) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('This platform only supports controlling trainer apps on other devices'),
),
);
} else {
initializeActions(local);
setState(() {
_local = local;
_reloadRequirements();
});
}
},
),
Expanded(
child: Card(
margin: EdgeInsets.symmetric(horizontal: 16),
child: Stepper(
currentStep: _currentStep,
connectorColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
),
onStepContinue:
_currentStep < _requirements.length
? () {
setState(() {
_currentStep += 1;
});
}
: null,
onStepContinue: _currentStep < _requirements.length
? () {
setState(() {
_currentStep += 1;
});
}
: null,
onStepTapped: (step) {
if (_requirements[step].status) {
return;
@@ -152,46 +146,38 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
});
},
controlsBuilder: (context, details) => Container(),
steps:
_requirements
.mapIndexed(
(index, req) => Step(
title: Text(req.name),
content: Container(
padding: const EdgeInsets.only(top: 16.0),
alignment: Alignment.centerLeft,
child:
(index == _currentStep
? req.build(context, () {
steps: _requirements
.mapIndexed(
(index, req) => Step(
title: Text(req.name, style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: req.description != null ? Text(req.description!) : null,
content: Container(
padding: const EdgeInsets.only(top: 16.0),
alignment: Alignment.centerLeft,
child:
(index == _currentStep
? req.build(context, () {
_reloadRequirements();
})
: null) ??
ElevatedButton(
onPressed: req.status
? null
: () => _callRequirement(req, context, () {
_reloadRequirements();
})
: null) ??
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
if (req.description != null)
Text(req.description!, style: TextStyle(fontSize: 16)),
ElevatedButton(
onPressed:
req.status
? null
: () => _callRequirement(req, context, () {
_reloadRequirements();
}),
child: Text(req.name),
),
],
),
),
state: req.status ? StepState.complete : StepState.indexed,
),
)
.toList(),
}),
child: Text(req.name),
),
),
state: req.status ? StepState.complete : StepState.indexed,
),
)
.toList(),
),
),
],
),
),
],
),
);
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/markdown.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../widgets/logviewer.dart';
@@ -47,6 +47,8 @@ class _ScanWidgetState extends State<ScanWidget> {
return Container(
constraints: BoxConstraints(minHeight: 200),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ValueListenableBuilder(
valueListenable: connection.isScanning,
@@ -54,14 +56,17 @@ class _ScanWidgetState extends State<ScanWidget> {
if (isScanning) {
return Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Scanning for devices... Make sure they are powered on and in range and not connected to another device.',
),
TextButton(
onPressed: () {
launchUrlString(
'https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-platforms',
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
child: const Text("Show Troubleshooting Guide"),

View File

@@ -2,15 +2,12 @@ import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
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/utils/actions/android.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/menu.dart';
import 'package:swift_control/widgets/testbed.dart';
@@ -20,6 +17,7 @@ import '../bluetooth/messages/click_notification.dart';
import '../bluetooth/messages/notification.dart';
import '../bluetooth/messages/play_notification.dart';
import '../bluetooth/messages/ride_notification.dart';
import '../utils/actions/base_actions.dart';
import '../utils/keymap/apps/custom_app.dart';
import '../utils/keymap/buttons.dart';
import '../utils/keymap/keymap.dart';
@@ -160,7 +158,6 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
final relativeY = min(100.0, keyPair.touchPosition.dy) / 100.0;
//print('Relative position: $relativeX, $relativeY');
final flutterView = WidgetsBinding.instance.platformDispatcher.views.first;
final isTouchOnly = actionHandler is RemoteActions || actionHandler is AndroidActions;
// figure out notch height for e.g. macOS. On Windows the display size is not available (0,0).
final differenceInHeight = (flutterView.display.size.height > 0 && !Platform.isIOS)
@@ -196,7 +193,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
width: iconSize,
height: iconSize,
child: Icon(
isTouchOnly ? Icons.touch_app_outlined : keyPair.icon,
keyPair.icon,
size: iconSize - 12,
shadows: [
Shadow(color: Colors.white, offset: Offset(1, 1)),
@@ -210,31 +207,33 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
PopupMenuButton<PhysicalKeyboardKey>(
enabled: enableTouch,
itemBuilder: (context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(
leading: Icon(Icons.keyboard_alt_outlined),
title: const Text('Simulate Keyboard shortcut'),
if (actionHandler.supportedModes.contains(SupportedMode.keyboard))
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(() {});
},
),
if (actionHandler.supportedModes.contains(SupportedMode.touch))
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(() {});
},
),
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: () {
@@ -251,49 +250,50 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
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;
if (actionHandler.supportedModes.contains(SupportedMode.media)) PopupMenuDivider(),
if (actionHandler.supportedModes.contains(SupportedMode.media))
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'),
setState(() {});
},
child: ListTile(
leading: Icon(Icons.music_note_outlined),
trailing: Icon(Icons.arrow_right),
title: Text('Simulate Media key'),
),
),
),
),
PopupMenuDivider(),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
@@ -314,7 +314,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
},
child: Row(
children: [
KeypairExplanation(withKey: true, keyPair: keyPair, isTouchOnly: isTouchOnly),
KeypairExplanation(withKey: true, keyPair: keyPair),
Icon(Icons.more_vert),
],
),
@@ -338,7 +338,10 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
final RenderBox renderObject = context.findRenderObject() as RenderBox;
return renderObject.globalToLocal(position).scale(scale, scale);
},
feedback: Material(color: Colors.transparent, child: icon),
feedback: Material(
color: Colors.transparent,
child: icon,
),
childWhenDragging: const SizedBox.shrink(),
onDragEnd: (details) {
// otherwise simulated touch will move it
@@ -473,10 +476,9 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
class KeypairExplanation extends StatelessWidget {
final bool withKey;
final bool isTouchOnly;
final KeyPair keyPair;
const KeypairExplanation({super.key, required this.keyPair, this.withKey = false, required this.isTouchOnly});
const KeypairExplanation({super.key, required this.keyPair, this.withKey = false});
@override
Widget build(BuildContext context) {
@@ -485,12 +487,12 @@ class KeypairExplanation extends StatelessWidget {
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (withKey)
KeyWidget(
label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n'),
Row(
children: keyPair.buttons.map((b) => ButtonWidget(button: b)).toList(),
)
else
Icon(isTouchOnly ? Icons.touch_app : keyPair.icon),
if (keyPair.physicalKey != null && !isTouchOnly) ...[
Icon(keyPair.icon),
if (keyPair.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[
KeyWidget(
label: switch (keyPair.physicalKey) {
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
@@ -502,11 +504,11 @@ class KeypairExplanation extends StatelessWidget {
_ => keyPair.logicalKey?.keyLabel ?? 'Unknown',
},
),
if (keyPair.isLongPress) Text('long'),
if (keyPair.isLongPress) Text('long\npress', style: TextStyle(fontSize: 10)),
] else ...[
if (!withKey)
KeyWidget(label: 'X: ${keyPair.touchPosition.dx.toInt()}, Y: ${keyPair.touchPosition.dy.toInt()}'),
if (keyPair.isLongPress) Text('long'),
if (keyPair.isLongPress) Text('long\npress', style: TextStyle(fontSize: 10)),
],
],
);

View File

@@ -7,6 +7,9 @@ abstract final class AppTheme {
static ThemeData light = FlexThemeData.light(
// Using FlexColorScheme built-in FlexScheme enum based colors
scheme: FlexScheme.redM3,
primary: Color(0xFF0E74B7),
primaryContainer: Color(0x7C0E9297),
onPrimaryContainer: Colors.black,
// Component theme configurations for light mode.
subThemesData: const FlexSubThemesData(
interactionEffects: true,
@@ -23,27 +26,28 @@ abstract final class AppTheme {
);
// The FlexColorScheme defined dark mode ThemeData.
static ThemeData dark = FlexThemeData.dark(
// Using FlexColorScheme built-in FlexScheme enum based colors.
scheme: FlexScheme.redM3,
// Component theme configurations for dark mode.
subThemesData: const FlexSubThemesData(
interactionEffects: true,
tintedDisabledControls: true,
blendOnColors: true,
useM2StyleDividerInM3: true,
inputDecoratorIsFilled: true,
inputDecoratorBorderType: FlexInputBorderType.outline,
alignedDropdown: true,
navigationRailUseIndicator: true,
),
// Direct ThemeData properties.
visualDensity: FlexColorScheme.comfortablePlatformDensity,
cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true),
).copyWith(
scaffoldBackgroundColor: Color(0xff0b1623),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
),
);
static ThemeData dark =
FlexThemeData.dark(
// Using FlexColorScheme built-in FlexScheme enum based colors.
scheme: FlexScheme.redM3,
// Component theme configurations for dark mode.
subThemesData: const FlexSubThemesData(
interactionEffects: true,
tintedDisabledControls: true,
blendOnColors: true,
useM2StyleDividerInM3: true,
inputDecoratorIsFilled: true,
inputDecoratorBorderType: FlexInputBorderType.outline,
alignedDropdown: true,
navigationRailUseIndicator: true,
),
// Direct ThemeData properties.
visualDensity: FlexColorScheme.comfortablePlatformDensity,
cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true),
).copyWith(
scaffoldBackgroundColor: Color(0xff0b1623),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
),
);
}

View File

@@ -12,6 +12,8 @@ import '../single_line_exception.dart';
class AndroidActions extends BaseActions {
WindowEvent? windowInfo;
AndroidActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.media]});
@override
void init(SupportedApp? supportedApp) {
super.init(supportedApp);
@@ -41,7 +43,7 @@ class AndroidActions extends BaseActions {
return "Key pressed: ${keyPair.toString()}";
}
}
final point = supportedApp!.resolveTouchPosition(action: button, windowInfo: windowInfo);
final point = resolveTouchPosition(action: button, windowInfo: windowInfo);
if (point != Offset.zero) {
accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp

View File

@@ -1,18 +1,47 @@
import 'package:accessibility/accessibility.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import '../keymap/apps/supported_app.dart';
enum SupportedMode { keyboard, touch, media }
abstract class BaseActions {
final List<SupportedMode> supportedModes;
SupportedApp? supportedApp;
BaseActions({required this.supportedModes});
void init(SupportedApp? supportedApp) {
this.supportedApp = supportedApp;
}
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
final keyPair = supportedApp!.keymap.getKeyPair(action);
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
// convert relative position to absolute position based on window info
if (windowInfo != null && windowInfo.right != 0) {
final x = windowInfo.left + (keyPair.touchPosition.dx / 100) * (windowInfo.right - windowInfo.left);
final y = windowInfo.top + (keyPair.touchPosition.dy / 100) * (windowInfo.bottom - windowInfo.top);
return Offset(x, y);
} else {
final screenSize = WidgetsBinding.instance.platformDispatcher.views.first.display.size;
print('Screen size: $screenSize');
final x = (keyPair.touchPosition.dx / 100) * screenSize.width;
final y = (keyPair.touchPosition.dy / 100) * screenSize.height;
return Offset(x, y);
}
}
return Offset.zero;
}
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false});
}
class StubActions extends BaseActions {
StubActions({super.supportedModes = const []});
@override
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
return Future.value(action.name);

View File

@@ -4,6 +4,8 @@ import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
class DesktopActions extends BaseActions {
DesktopActions({super.supportedModes = const [SupportedMode.keyboard, SupportedMode.touch, SupportedMode.media]});
// Track keys that are currently held down in long press mode
@override
@@ -31,7 +33,7 @@ class DesktopActions extends BaseActions {
return 'Key released: $keyPair';
}
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
final point = resolveTouchPosition(action: action, windowInfo: null);
if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateMouseClickDown(point);
await keyPressSimulator.simulateMouseClickUp(point);

View File

@@ -1,5 +1,6 @@
import 'dart:ui';
import 'package:accessibility/accessibility.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/zwift_click.dart';
@@ -12,6 +13,8 @@ import 'package:universal_ble/universal_ble.dart';
import '../requirements/remote.dart';
class RemoteActions extends BaseActions {
RemoteActions({super.supportedModes = const [SupportedMode.touch]});
@override
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
if (supportedApp == null) {
@@ -30,7 +33,7 @@ class RemoteActions extends BaseActions {
if (keyPair.physicalKey != null && keyPair.touchPosition == Offset.zero) {
return ('Physical key actions are not supported, yet');
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
final point = resolveTouchPosition(action: action, windowInfo: null);
final point2 = point; //Offset(100, 99.0);
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
await sendAbsMouseReport(1, point2.dx.toInt(), point2.dy.toInt());
@@ -39,6 +42,16 @@ class RemoteActions extends BaseActions {
}
}
@override
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
// for remote actions we use the relative position only
final keyPair = supportedApp!.keymap.getKeyPair(action);
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
return keyPair.touchPosition;
}
return Offset.zero;
}
Uint8List absMouseReport(int buttons3bit, int x, int y) {
final b = buttons3bit & 0x07;
final xi = x.clamp(0, 100);

View File

@@ -1,9 +1,7 @@
import 'package:accessibility/accessibility.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import '../../single_line_exception.dart';
import '../buttons.dart';
import '../keymap.dart';
@@ -11,16 +9,11 @@ class CustomApp extends SupportedApp {
final String profileName;
CustomApp({this.profileName = 'Custom'})
: super(name: profileName, packageName: "custom_$profileName", keymap: Keymap(keyPairs: []));
@override
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
final keyPair = keymap.getKeyPair(action);
if (keyPair == null || keyPair.touchPosition == Offset.zero) {
throw SingleLineException("No key pair found for action: $action. You may want to adjust the keymap.");
}
return keyPair.touchPosition;
}
: super(
name: profileName,
packageName: "custom_$profileName",
keymap: Keymap(keyPairs: []),
);
List<String> encodeKeymap() {
// encode to save in preferences

View File

@@ -1,11 +1,6 @@
import 'dart:ui';
import 'package:accessibility/accessibility.dart';
import 'package:swift_control/utils/keymap/apps/biketerra.dart';
import 'package:swift_control/utils/keymap/apps/training_peaks.dart';
import '../../single_line_exception.dart';
import '../buttons.dart';
import '../keymap.dart';
import 'custom_app.dart';
import 'my_whoosh.dart';
@@ -15,22 +10,6 @@ abstract class SupportedApp {
final String name;
final Keymap keymap;
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
if (this is CustomApp) {
final keyPair = keymap.getKeyPair(action);
if (keyPair == null || keyPair.touchPosition == Offset.zero) {
throw SingleLineException("No key pair found for action: $action");
}
return keyPair.touchPosition;
} else {
final keyPair = keymap.getKeyPair(action);
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
return keyPair.touchPosition;
}
}
return Offset.zero;
}
const SupportedApp({required this.name, required this.packageName, required this.keymap});
static final List<SupportedApp> supportedApps = [MyWhoosh(), TrainingPeaks(), Biketerra(), CustomApp()];

View File

@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
enum InGameAction {
shiftUp,
shiftDown,
@@ -15,35 +17,37 @@ enum InGameAction {
enum ZwiftButton {
// left controller
navigationUp._(InGameAction.increaseResistance),
navigationDown._(InGameAction.decreaseResistance),
navigationLeft._(InGameAction.navigateLeft),
navigationRight._(InGameAction.navigateRight),
navigationUp._(InGameAction.increaseResistance, icon: Icons.keyboard_arrow_up, color: Colors.black),
navigationDown._(InGameAction.decreaseResistance, icon: Icons.keyboard_arrow_down, color: Colors.black),
navigationLeft._(InGameAction.navigateLeft, icon: Icons.keyboard_arrow_left, color: Colors.black),
navigationRight._(InGameAction.navigateRight, icon: Icons.keyboard_arrow_right, color: Colors.black),
onOffLeft._(InGameAction.toggleUi),
sideButtonLeft._(InGameAction.shiftDown),
paddleLeft._(InGameAction.shiftDown),
// zwift ride only
shiftUpLeft._(InGameAction.shiftDown),
shiftDownLeft._(InGameAction.shiftDown),
shiftUpLeft._(InGameAction.shiftDown, icon: Icons.minimize_rounded, color: Colors.black),
shiftDownLeft._(InGameAction.shiftDown, icon: Icons.minimize_rounded, color: Colors.black),
powerUpLeft._(InGameAction.shiftDown),
// right controller
a._(null),
b._(null),
z._(null),
y._(null),
a._(null, color: Colors.lightGreen),
b._(null, color: Colors.pinkAccent),
z._(null, color: Colors.deepOrangeAccent),
y._(null, color: Colors.lightBlue),
onOffRight._(InGameAction.toggleUi),
sideButtonRight._(InGameAction.shiftUp),
paddleRight._(InGameAction.shiftUp),
// zwift ride only
shiftUpRight._(InGameAction.shiftUp),
shiftUpRight._(InGameAction.shiftUp, icon: Icons.add, color: Colors.black),
shiftDownRight._(InGameAction.shiftUp),
powerUpRight._(InGameAction.shiftUp);
final InGameAction? action;
const ZwiftButton._(this.action);
final Color? color;
final IconData? icon;
const ZwiftButton._(this.action, {this.color, this.icon});
@override
String toString() {

View File

@@ -3,8 +3,11 @@ import 'dart:convert';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import '../actions/base_actions.dart';
class Keymap {
static Keymap custom = Keymap(keyPairs: []);
@@ -16,9 +19,8 @@ class Keymap {
String toString() {
return keyPairs.joinToString(
separator: ('\n---------\n'),
transform:
(k) =>
'''Button: ${k.buttons.joinToString(transform: (e) => e.name)}\nKeyboard key: ${k.logicalKey?.keyLabel ?? 'Not assigned'}\nAction: ${k.buttons.firstOrNull?.action}${k.touchPosition != Offset.zero ? '\nTouch Position: ${k.touchPosition.toString()}' : ''}${k.isLongPress ? '\nLong Press: Enabled' : ''}''',
transform: (k) =>
'''Button: ${k.buttons.joinToString(transform: (e) => e.name)}\nKeyboard key: ${k.logicalKey?.keyLabel ?? 'Not assigned'}\nAction: ${k.buttons.firstOrNull?.action}${k.touchPosition != Offset.zero ? '\nTouch Position: ${k.touchPosition.toString()}' : ''}${k.isLongPress ? '\nLong Press: Enabled' : ''}''',
);
}
@@ -60,15 +62,20 @@ class KeyPair {
physicalKey == PhysicalKeyboardKey.audioVolumeUp ||
physicalKey == PhysicalKeyboardKey.audioVolumeDown;
IconData get icon => switch (physicalKey) {
PhysicalKeyboardKey.mediaPlayPause ||
PhysicalKeyboardKey.mediaStop ||
PhysicalKeyboardKey.mediaTrackPrevious ||
PhysicalKeyboardKey.mediaTrackNext ||
PhysicalKeyboardKey.audioVolumeUp ||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
_ => physicalKey != null ? Icons.keyboard : Icons.touch_app,
};
IconData get icon {
return switch (physicalKey) {
PhysicalKeyboardKey.mediaPlayPause ||
PhysicalKeyboardKey.mediaStop ||
PhysicalKeyboardKey.mediaTrackPrevious ||
PhysicalKeyboardKey.mediaTrackNext ||
PhysicalKeyboardKey.audioVolumeUp ||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
_ =>
physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)
? Icons.keyboard
: Icons.touch_app,
};
}
@override
String toString() {
@@ -101,27 +108,23 @@ class KeyPair {
final decoded = jsonDecode(data);
// Support both percentage-based (new) and pixel-based (old) formats for backward compatibility
final Offset touchPosition =
decoded.containsKey('touchPosition')
? Offset(
(decoded['touchPosition']['x'] as num).toDouble(),
(decoded['touchPosition']['y'] as num).toDouble(),
)
: Offset.zero;
final Offset touchPosition = decoded.containsKey('touchPosition')
? Offset(
(decoded['touchPosition']['x'] as num).toDouble(),
(decoded['touchPosition']['y'] as num).toDouble(),
)
: Offset.zero;
return KeyPair(
buttons:
decoded['actions']
.map<ZwiftButton>((e) => ZwiftButton.values.firstWhere((element) => element.name == e))
.toList(),
logicalKey:
decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0
? LogicalKeyboardKey(int.parse(decoded['logicalKey']))
: null,
physicalKey:
decoded.containsKey('physicalKey') && int.parse(decoded['physicalKey']) != 0
? PhysicalKeyboardKey(int.parse(decoded['physicalKey']))
: null,
buttons: decoded['actions']
.map<ZwiftButton>((e) => ZwiftButton.values.firstWhere((element) => element.name == e))
.toList(),
logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0
? LogicalKeyboardKey(int.parse(decoded['logicalKey']))
: null,
physicalKey: decoded.containsKey('physicalKey') && int.parse(decoded['physicalKey']) != 0
? PhysicalKeyboardKey(int.parse(decoded['physicalKey']))
: null,
touchPosition: touchPosition,
isLongPress: decoded['isLongPress'] ?? false,
);

View File

@@ -23,7 +23,10 @@ class ChangelogDialog extends StatelessWidget {
),
],
),
content: Container(constraints: BoxConstraints(minWidth: 460), child: MarkdownWidget(markdown: latestVersion)),
content: Container(
constraints: BoxConstraints(minWidth: 460),
child: MarkdownWidget(markdown: latestVersion),
),
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Got it!'))],
);
}
@@ -35,7 +38,11 @@ class ChangelogDialog extends StatelessWidget {
final entry = await rootBundle.loadString('CHANGELOG.md');
if (context.mounted) {
final markdown = Markdown.fromString(entry);
showDialog(context: context, builder: (context) => ChangelogDialog(entry: markdown));
showDialog(
context: context,
useRootNavigator: true,
builder: (context) => ChangelogDialog(entry: markdown),
);
}
} catch (e) {
print('Failed to load changelog for dialog: $e');

View File

@@ -2,11 +2,11 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/device.dart';
import 'package:swift_control/utils/actions/android.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
import '../pages/touch_area.dart';
import '../utils/actions/base_actions.dart';
class KeymapExplanation extends StatelessWidget {
final Keymap keymap;
@@ -17,17 +17,19 @@ class KeymapExplanation extends StatelessWidget {
Widget build(BuildContext context) {
final connectedDevice = connection.devices.firstOrNull;
final isTouchOnlyActions = actionHandler is AndroidActions || actionHandler is RemoteActions;
final availableKeypairs = keymap.keyPairs.filter(
(e) => connectedDevice?.availableButtons.containsAny(e.buttons) == true,
);
final keyboardGroups = availableKeypairs
.filter((e) => e.physicalKey != null && !isTouchOnlyActions)
.filter((e) => e.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard))
.groupBy((element) => '${element.physicalKey?.usbHidUsage}-${element.isLongPress}');
final touchGroups = availableKeypairs
.filter((e) => (e.physicalKey == null || isTouchOnlyActions) && e.touchPosition != Offset.zero)
.filter(
(e) =>
(e.physicalKey == null || !actionHandler.supportedModes.contains(SupportedMode.keyboard)) &&
e.touchPosition != Offset.zero,
)
.groupBy((element) => '${element.touchPosition.dx}-${element.touchPosition.dy}-${element.isLongPress}');
return Column(
@@ -39,14 +41,22 @@ class KeymapExplanation extends StatelessWidget {
Text('No key mappings found. Please customize the keymap.')
else
Table(
border: TableBorder.all(color: Theme.of(context).colorScheme.primaryContainer),
border: TableBorder.symmetric(
borderRadius: BorderRadius.circular(9),
inside: BorderSide(
color: Theme.of(context).colorScheme.primaryContainer,
),
outside: BorderSide(
color: Theme.of(context).colorScheme.primaryContainer,
),
),
children: [
TableRow(
children: [
Padding(
padding: const EdgeInsets.all(6),
child: Text(
'Button on your ${connectedDevice?.device.name?.screenshot ?? connectedDevice?.runtimeType}',
'Button on your ${connectedDevice?.device.name?.screenshot ?? connectedDevice?.runtimeType ?? 'device'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
@@ -71,13 +81,13 @@ 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)),
IntrinsicWidth(child: ButtonWidget(button: button)),
],
),
),
Padding(
padding: const EdgeInsets.all(6),
child: KeypairExplanation(keyPair: pair.value.first, isTouchOnly: isTouchOnlyActions),
child: KeypairExplanation(keyPair: pair.value.first),
),
],
),
@@ -94,13 +104,13 @@ 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),
ButtonWidget(button: button),
],
),
),
Padding(
padding: const EdgeInsets.all(6),
child: KeypairExplanation(keyPair: pair.value.first, isTouchOnly: isTouchOnlyActions),
child: KeypairExplanation(keyPair: pair.value.first),
),
],
),
@@ -142,6 +152,44 @@ class KeyWidget extends StatelessWidget {
}
}
class ButtonWidget extends StatelessWidget {
final ZwiftButton button;
const ButtonWidget({super.key, required this.button});
@override
Widget build(BuildContext context) {
return IntrinsicWidth(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
constraints: BoxConstraints(minWidth: 30),
decoration: BoxDecoration(
border: Border.all(color: button.color != null ? Colors.black : Theme.of(context).colorScheme.primary),
shape: button.color != null || button.icon != null ? BoxShape.circle : BoxShape.rectangle,
borderRadius: button.color != null || button.icon != null ? null : BorderRadius.circular(4),
color: button.color ?? Theme.of(context).colorScheme.primaryContainer,
),
child: Center(
child: button.icon != null
? Icon(
button.icon,
color: Colors.white,
size: 14,
)
: Text(
button.name.splitByUpperCase(),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
fontWeight: button.color != null ? FontWeight.bold : null,
color: button.color != null ? Colors.white : Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
),
);
}
}
extension SplitByUppercase on String {
String splitByUpperCase() {
return replaceAllMapped(RegExp(r'([a-z])([A-Z])'), (match) => '${match.group(1)} ${match.group(2)}').capitalize();

View File

@@ -29,12 +29,14 @@ class _LogviewerState extends State<LogViewer> {
_actions.add((date: DateTime.now(), entry: data.toString()));
_actions = _actions.takeLast(60).toList();
});
// scroll to the bottom
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 60),
curve: Curves.easeInOut,
);
if (_scrollController.hasClients) {
// scroll to the bottom
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 60),
curve: Curves.easeInOut,
);
}
}
});
}
@@ -48,48 +50,56 @@ class _LogviewerState extends State<LogViewer> {
@override
Widget build(BuildContext context) {
return SelectionArea(
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"],
return _actions.isEmpty
? Container()
: SafeArea(
child: Card(
child: Padding(
padding: const EdgeInsets.all(8),
child: SelectionArea(
child: GestureDetector(
onLongPress: () {
setState(() {
_actions = [];
});
},
child: ListView(
physics: const NeverScrollableScrollPhysics(),
controller: _scrollController,
shrinkWrap: true,
reverse: true,
children: _actions
.map(
(action) => Text.rich(
TextSpan(
children: [
TextSpan(
text: action.date.toString().split(" ").last,
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: "monospace",
fontFamilyFallback: <String>["Courier"],
),
),
TextSpan(
text: " ${action.entry}",
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.bold,
),
),
],
),
),
),
TextSpan(
text: " ${action.entry}",
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.bold,
),
),
],
),
)
.toList(),
),
)
.toList(),
),
),
);
),
),
),
),
);
}
}

View File

@@ -39,12 +39,14 @@ class _AppTitleState extends State<AppTitle> {
final apkUrl = assets.firstOrNullWhere((asset) => asset['name'].endsWith('.apk'))['browser_download_url'];
return apkUrl;
} else if (Platform.isMacOS) {
final dmgUrl =
assets.firstOrNullWhere((asset) => asset['name'].endsWith('.macos.zip'))['browser_download_url'];
final dmgUrl = assets.firstOrNullWhere(
(asset) => asset['name'].endsWith('.macos.zip'),
)['browser_download_url'];
return dmgUrl;
} else if (Platform.isWindows) {
final appImageUrl =
assets.firstOrNullWhere((asset) => asset['name'].endsWith('.windows.zip'))['browser_download_url'];
final appImageUrl = assets.firstOrNullWhere(
(asset) => asset['name'].endsWith('.windows.zip'),
)['browser_download_url'];
return appImageUrl;
}
}
@@ -115,7 +117,7 @@ class _AppTitleState extends State<AppTitle> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('SwiftControl'),
Text('SwiftControl', style: TextStyle(fontWeight: FontWeight.bold)),
if (_packageInfoValue != null)
Text(
'v${_packageInfoValue!.version}',