restructure UI to make target selection easier to understand as well as how to get help

This commit is contained in:
Jonas Bark
2025-10-21 10:10:40 +02:00
parent 35e499720b
commit 9f58dca10e
9 changed files with 279 additions and 130 deletions

View File

@@ -75,7 +75,6 @@ jobs:
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
- name: 🚀 Shorebird Patch macOS
if: false
uses: shorebirdtech/shorebird-patch@v1
with:
platform: macos
@@ -87,7 +86,7 @@ jobs:
with:
platform: android
release-version: latest
args: '--allow-asset-diffs --allow-native-diffs'
args: '--allow-asset-diffs'
- name: 🚀 Shorebird Patch iOS
uses: shorebirdtech/shorebird-patch@v1
@@ -136,7 +135,6 @@ jobs:
token: ${{ secrets.TOKEN }}
windows:
if: false
name: Patch Windows
runs-on: windows-latest

View File

@@ -10,7 +10,6 @@ import 'package:swift_control/utils/actions/android.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/utils/settings/settings.dart';
import 'package:window_manager/window_manager.dart';
import 'bluetooth/connection.dart';
import 'utils/actions/base_actions.dart';
@@ -25,13 +24,6 @@ const screenshotMode = false;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
initializeActions(true);
if (actionHandler is DesktopActions) {
// Must add this line.
await windowManager.ensureInitialized();
windowManager.setSize(Size(1280, 800));
}
runApp(const SwiftPlayApp());
}

View File

@@ -11,6 +11,7 @@ import 'package:swift_control/bluetooth/devices/zwift/zwift_device.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/utils/actions/base_actions.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/loading_widget.dart';
@@ -159,8 +160,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
),
child: Column(
children: [
if (connection.devices.isEmpty)
Text('No devices connected. Go back and connect a device to get started.'),
if (connection.devices.isEmpty) Text('No devices connected. Searching...'),
...connection.devices.map(
(device) => Row(
children: [

View File

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/changelog_dialog.dart';
import 'package:swift_control/widgets/menu.dart';
@@ -21,7 +22,6 @@ class RequirementsPage extends StatefulWidget {
class _RequirementsPageState extends State<RequirementsPage> with WidgetsBindingObserver {
int _currentStep = 0;
var _local = true;
List<PlatformRequirement> _requirements = [];
@@ -30,8 +30,6 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
super.initState();
WidgetsBinding.instance.addObserver(this);
_local = kIsWeb || !Platform.isIOS;
// call after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
settings.init().then((_) {
@@ -94,90 +92,61 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
),
body: _requirements.isEmpty
? Center(child: CircularProgressIndicator())
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
if (!kIsWeb)
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 (Platform.isIOS) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('This platform only supports controlling trainer apps on other devices'),
),
);
} else {
initializeActions(local);
: Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Stepper(
currentStep: _currentStep,
connectorColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
),
onStepContinue: _currentStep < _requirements.length
? () {
setState(() {
_local = local;
_reloadRequirements();
_currentStep += 1;
});
}
},
),
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,
onStepTapped: (step) {
if (_requirements[step].status) {
return;
}
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
if (hasEarlierIncomplete && !kDebugMode) {
return;
}
setState(() {
_currentStep = step;
});
},
controlsBuilder: (context, details) => Container(),
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();
}),
child: Text(req.name),
),
: null,
onStepTapped: (step) {
if (_requirements[step].status && _requirements[step] is! TargetRequirement) {
return;
}
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
if (hasEarlierIncomplete) {
return;
}
setState(() {
_currentStep = step;
});
},
controlsBuilder: (context, details) => Container(),
steps: _requirements
.mapIndexed(
(index, req) => Step(
title: Text(req.name, style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: req.buildDescription() ?? (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();
}),
child: Text(req.name),
),
state: req.status ? StepState.complete : StepState.indexed,
),
)
.toList(),
),
),
),
],
),
state: req.status ? StepState.complete : StepState.indexed,
),
)
.toList(),
),
),
);
}
@@ -189,7 +158,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
}
void _reloadRequirements() {
getRequirements(_local).then((req) {
getRequirements(settings.getLastTarget() == Target.thisDevice).then((req) {
_requirements = req;
_currentStep = req.indexWhere((req) => !req.status);
if (mounted) {

View File

@@ -79,3 +79,144 @@ class BluetoothScanning extends PlatformRequirement {
return ScanWidget();
}
}
typedef BoolFunction = bool Function();
enum Target {
thisDevice(title: 'This device', description: 'Trainer app runs on this device', icon: Icons.devices),
iPad(
title: 'iPad',
description: 'Remotely control the trainer app on an iPad',
icon: Icons.settings_remote_outlined,
),
android(
title: 'Android Device',
description: 'Remotely control the trainer app on an Android device',
icon: Icons.settings_remote_outlined,
),
macOS(
title: 'Mac',
description: 'Remotely control the trainer app on a Mac',
icon: Icons.settings_remote_outlined,
),
windows(
title: 'Windows PC',
description: 'Remotely control the trainer app on a Windows PC',
icon: Icons.settings_remote_outlined,
);
final String title;
final String description;
final IconData icon;
const Target({required this.title, required this.description, required this.icon});
bool get isCompatible {
return switch (this) {
Target.thisDevice => !Platform.isIOS,
_ => true,
};
}
String? get warning {
return switch (this) {
Target.android when Platform.isAndroid =>
"Download and use SwiftControl on that Android device or select 'This device'.",
Target.macOS when Platform.isMacOS =>
"Download and use SwiftControl on that macOS device or select 'This device'.",
Target.windows when Platform.isWindows =>
"Download and use SwiftControl on that Windows device or select 'This device'.",
Target.android => "Download and use SwiftControl on that Android device.",
Target.macOS => "Download and use SwiftControl on that macOS device.",
Target.windows => "Download and use SwiftControl on that Windows device.",
_ => null,
};
}
}
class TargetRequirement extends PlatformRequirement {
TargetRequirement()
: super(
'Select Target Device',
description: 'Select your Target Device where you want to run your trainer app on',
) {
status = false;
}
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Future<void> getStatus() async {
status = settings.getLastTarget() != null;
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
return DropdownMenu<Target>(
dropdownMenuEntries: Target.values.map((target) {
return DropdownMenuEntry(
value: target,
label: target.title,
enabled: target.isCompatible,
trailingIcon: Icon(target.icon),
labelWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)),
Text(
target.isCompatible
? target.description
: 'Due to iOS restrictions only controlling trainer apps on other devices is supported :(',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
);
}).toList(),
hintText: name,
initialSelection: settings.getLastTarget(),
onSelected: (target) async {
if (target != null) {
await settings.setLastTarget(target);
initializeActions(target == Target.thisDevice);
if (target.warning != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(target.warning!),
duration: Duration(seconds: 10),
),
);
}
onUpdate();
}
},
);
}
@override
Widget? buildDescription() {
final target = settings.getLastTarget();
if (target != null) {
if (target.warning != null) {
return Row(
spacing: 8,
children: [
Icon(Icons.warning, color: Colors.red, size: 16),
Expanded(
child: Text(
settings.getLastTarget()!.warning!,
style: TextStyle(color: Colors.red),
),
),
],
);
} else {
return Text(target.title);
}
} else {
return null;
}
}
}

View File

@@ -22,6 +22,10 @@ abstract class PlatformRequirement {
Widget? build(BuildContext context, VoidCallback onUpdate) {
return null;
}
Widget? buildDescription() {
return null;
}
}
Future<List<PlatformRequirement>> getRequirements(bool local) async {
@@ -34,15 +38,26 @@ Future<List<PlatformRequirement>> getRequirements(bool local) async {
list = [BluetoothTurnedOn(), BluetoothScanning()];
}
} else if (Platform.isMacOS) {
list = [BluetoothTurnedOn(), local ? KeyboardRequirement() : RemoteRequirement(), BluetoothScanning()];
list = [
TargetRequirement(),
BluetoothTurnedOn(),
local ? KeyboardRequirement() : RemoteRequirement(),
BluetoothScanning(),
];
} else if (Platform.isIOS) {
list = [BluetoothTurnedOn(), RemoteRequirement(), BluetoothScanning()];
list = [TargetRequirement(), BluetoothTurnedOn(), RemoteRequirement(), BluetoothScanning()];
} else if (Platform.isWindows) {
list = [BluetoothTurnedOn(), local ? KeyboardRequirement() : RemoteRequirement(), BluetoothScanning()];
list = [
TargetRequirement(),
BluetoothTurnedOn(),
local ? KeyboardRequirement() : RemoteRequirement(),
BluetoothScanning(),
];
} else if (Platform.isAndroid) {
final deviceInfoPlugin = DeviceInfoPlugin();
final deviceInfo = await deviceInfoPlugin.androidInfo;
list = [
TargetRequirement(),
BluetoothTurnedOn(),
local ? AccessibilityRequirement() : RemoteRequirement(),
NotificationRequirement(),

View File

@@ -6,6 +6,7 @@ import 'package:flutter/material.dart' hide ConnectionState;
import 'package:permission_handler/permission_handler.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
@@ -18,11 +19,28 @@ bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
class RemoteRequirement extends PlatformRequirement {
RemoteRequirement() : super('Connect to your other device');
RemoteRequirement()
: super(
'Connect to your target device',
);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Widget? buildDescription() {
return settings.getLastTarget() == null
? null
: Text(
switch (settings.getLastTarget()) {
Target.iPad =>
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
_ =>
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required to use the remote feature.',
},
);
}
Future<void> reconnect() async {
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
@@ -74,8 +92,7 @@ class RemoteRequirement extends PlatformRequirement {
return;
}
}
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn &&
peripheralManager.state != BluetoothLowEnergyState.unknown) {
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
print('Waiting for peripheral manager to be powered on...');
await Future.delayed(Duration(seconds: 1));
}
@@ -284,6 +301,7 @@ class _PairWidgetState extends State<_PairWidget> {
Widget build(BuildContext context) {
return Column(
spacing: 10,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 10,
@@ -295,21 +313,9 @@ class _PairWidgetState extends State<_PairWidget> {
child: Text(_isAdvertising ? 'Stop Pairing' : 'Start Pairing'),
),
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
if (kDebugMode && !screenshotMode)
ElevatedButton(
onPressed: () {
(actionHandler as RemoteActions).sendAbsMouseReport(0, 90, 90);
(actionHandler as RemoteActions).sendAbsMouseReport(1, 90, 90);
(actionHandler as RemoteActions).sendAbsMouseReport(0, 90, 90);
},
child: Text('Test'),
),
],
),
if (_isAdvertising) ...[
Text(
'If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
),
TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')));

View File

@@ -4,8 +4,11 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:window_manager/window_manager.dart';
import '../../main.dart';
import '../actions/desktop.dart';
import '../keymap/apps/custom_app.dart';
class Settings {
@@ -13,6 +16,12 @@ class Settings {
Future<void> init() async {
prefs = await SharedPreferences.getInstance();
initializeActions(settings.getLastTarget() == Target.thisDevice);
if (actionHandler is DesktopActions) {
// Must add this line.
await windowManager.ensureInitialized();
}
try {
// Get screen size for migrations
@@ -136,6 +145,16 @@ class Settings {
return prefs.getString('last_seen_version');
}
Target? getLastTarget() {
final targetString = prefs.getString('last_target');
if (targetString == null) return null;
return Target.values.firstOrNullWhere((e) => e.name == targetString);
}
Future<void> setLastTarget(Target target) async {
await prefs.setString('last_target', target.name);
}
Future<void> setLastSeenVersion(String version) async {
await prefs.setString('last_seen_version', version);
}

View File

@@ -51,6 +51,29 @@ List<Widget> buildMenuButtons() {
),
SizedBox(width: 8),
],
PopupMenuButton(
itemBuilder: (BuildContext context) {
return [
PopupMenuItem(
child: Text('Troubleshooting Guide'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
),
PopupMenuItem(
child: Text('Get Support'),
onTap: () {
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
},
),
];
},
icon: Icon(Icons.help_outline),
),
SizedBox(width: 8),
const MenuButton(),
SizedBox(width: 8),
];
@@ -106,21 +129,7 @@ class MenuButton extends StatelessWidget {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
},
),
PopupMenuItem(
child: Text('Troubleshooting Guide'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
),
PopupMenuItem(
child: Text('Feedback'),
onTap: () {
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
},
),
PopupMenuItem(
child: Text('License'),
onTap: () {