mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
make the UI look nicer
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}',
|
||||
|
||||
Reference in New Issue
Block a user