diff --git a/lib/bluetooth/connection.dart b/lib/bluetooth/connection.dart index 9857667..e2fc52d 100644 --- a/lib/bluetooth/connection.dart +++ b/lib/bluetooth/connection.dart @@ -160,9 +160,6 @@ class Connection { if (existing != null) { existing.isConnected = true; signalChange(existing); - } else { - final linkDevice = LinkDevice(socket.remoteAddress.address); - _addDevices([linkDevice]); } }, onDisconnected: (socket) { diff --git a/lib/bluetooth/devices/link/link_device.dart b/lib/bluetooth/devices/link/link_device.dart index cf3c589..d178ed9 100644 --- a/lib/bluetooth/devices/link/link_device.dart +++ b/lib/bluetooth/devices/link/link_device.dart @@ -27,30 +27,42 @@ class LinkDevice extends BaseDevice { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('MyWhoosh Link: ${isConnected ? 'Connected' : 'Not connected'}'), - if (isConnected) - PopupMenuButton( - itemBuilder: (c) => [ - PopupMenuItem( - child: Text('Disconnect'), - onTap: () { - connection.disconnect(this, forget: true); - }, - ), - ], - ) - else - LoadingWidget( - futureCallback: () => connection.startMyWhooshServer(), - renderChild: (isLoading, tap) => ValueListenableBuilder( - valueListenable: whooshLink.isConnected, - builder: (c, isConnected, _) => TextButton( - onPressed: !isConnected ? tap : null, - child: isLoading || (!isConnected && whooshLink.isStarted.value) - ? SmallProgressIndicator() - : Text('Connect'), + Row( + children: [ + if (!isConnected) + LoadingWidget( + futureCallback: () => connection.startMyWhooshServer(), + renderChild: (isLoading, tap) => ValueListenableBuilder( + valueListenable: whooshLink.isConnected, + builder: (c, isConnected, _) => TextButton( + onPressed: !isConnected ? tap : null, + child: isLoading || (!isConnected && whooshLink.isStarted.value) + ? SmallProgressIndicator() + : Text('Connect'), + ), + ), ), + + PopupMenuButton( + itemBuilder: (c) => [ + if (isConnected) + PopupMenuItem( + child: Text('Disconnect'), + onTap: () { + connection.disconnect(this, forget: true); + }, + ) + else + PopupMenuItem( + child: Text('Stop'), + onTap: () { + whooshLink.stopServer(); + }, + ), + ], ), - ), + ], + ), ], ); } diff --git a/lib/bluetooth/devices/zwift/zwift_clickv2.dart b/lib/bluetooth/devices/zwift/zwift_clickv2.dart index 87cf3c9..29a5845 100644 --- a/lib/bluetooth/devices/zwift/zwift_clickv2.dart +++ b/lib/bluetooth/devices/zwift/zwift_clickv2.dart @@ -1,8 +1,10 @@ -import 'dart:typed_data'; - +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:swift_control/bluetooth/devices/zwift/constants.dart'; import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart'; import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:swift_control/pages/markdown.dart'; +import 'package:swift_control/widgets/warning.dart'; class ZwiftClickV2 extends ZwiftRide { ZwiftClickV2(super.scanResult) : super(isBeta: true); @@ -19,6 +21,58 @@ class ZwiftClickV2 extends ZwiftRide { await sendCommandBuffer(Uint8List.fromList([0xFF, 0x04, 0x00])); } + @override + Widget showInformation(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + super.showInformation(context), + + if (isConnected) + Warning( + 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: () { + sendCommand(Opcode.RESET, null); + }, + child: Text('Reset now'), + ), + TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'), + ), + ); + }, + child: Text('Troubleshooting'), + ), + if (kDebugMode) + TextButton( + onPressed: () { + test(); + }, + child: Text('Test'), + ), + ], + ), + ], + ), + ], + ); + } + Future test() async { await sendCommand(Opcode.RESET, null); //await sendCommand(Opcode.GET, Get(dataObjectId: VendorDO.PAGE_DEVICE_PAIRING.value)); // 0008 82E0 03 diff --git a/lib/pages/device.dart b/lib/pages/device.dart index ead9ced..e2069f1 100644 --- a/lib/pages/device.dart +++ b/lib/pages/device.dart @@ -5,15 +5,14 @@ import 'package:device_auto_rotate_checker/device_auto_rotate_checker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart'; -import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart'; +import 'package:swift_control/bluetooth/devices/link/link_device.dart'; import 'package:swift_control/main.dart'; -import 'package:swift_control/pages/markdown.dart'; import 'package:swift_control/utils/actions/desktop.dart'; import 'package:swift_control/utils/keymap/manager.dart'; import 'package:swift_control/widgets/keymap_explanation.dart'; import 'package:swift_control/widgets/logviewer.dart'; import 'package:swift_control/widgets/scan.dart'; +import 'package:swift_control/widgets/small_progress_indicator.dart'; import 'package:swift_control/widgets/testbed.dart'; import 'package:swift_control/widgets/title.dart'; import 'package:swift_control/widgets/warning.dart'; @@ -54,12 +53,16 @@ class _DevicePageState extends State with WidgetsBindingObserver { _checkAndShowChangelog(); }); - if (actionHandler is RemoteActions && !kIsWeb && Platform.isIOS) { + whooshLink.isStarted.addListener(() { + if (mounted) setState(() {}); + }); + + if (actionHandler is RemoteActions && !kIsWeb && Platform.isIOS && (actionHandler as RemoteActions).isConnected) { WidgetsBinding.instance.addPostFrameCallback((_) { // show snackbar to inform user that the app needs to stay in foreground _snackBarMessengerKey.currentState?.showSnackBar( SnackBar( - content: Text('To keep working properly the app needs to stay in the foreground.'), + content: Text('To simulate touches the app needs to stay in the foreground.'), duration: Duration(seconds: 5), ), ); @@ -98,14 +101,14 @@ class _DevicePageState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - if (actionHandler is RemoteActions && Platform.isIOS) { + if (actionHandler is RemoteActions && Platform.isIOS && (actionHandler as RemoteActions).isConnected) { UniversalBle.getBluetoothAvailabilityState().then((state) { if (state == AvailabilityState.poweredOn) { final requirement = RemoteRequirement(); requirement.reconnect(); _snackBarMessengerKey.currentState?.showSnackBar( SnackBar( - content: Text('To keep working properly the app needs to stay in the foreground.'), + content: Text('To simulate touches the app needs to stay in the foreground.'), duration: Duration(seconds: 5), ), ); @@ -191,32 +194,40 @@ class _DevicePageState extends State with WidgetsBindingObserver { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (connection.controllerDevices.isEmpty) - ScanWidget() - else - Container( - margin: const EdgeInsets.only(bottom: 8.0), - width: double.infinity, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.primaryContainer, - ), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text( - 'Connected Controllers', - style: TextStyle(fontWeight: FontWeight.bold), + Container( + margin: const EdgeInsets.only(bottom: 8.0), + width: double.infinity, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.primaryContainer, ), ), ), - ...connection.controllerDevices.map( - (device) => device.showInformation(context), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Connected Controllers', + style: TextStyle(fontWeight: FontWeight.bold), + ), + if (connection.controllerDevices.isEmpty) SmallProgressIndicator(), + ], + ), + ), ), + if (connection.controllerDevices.isEmpty) + ScanWidget() + else + ...connection.controllerDevices.map( + (device) => device.showInformation(context), + ), - if (connection.remoteDevices.isNotEmpty || actionHandler is RemoteActions) + if (connection.remoteDevices.isNotEmpty || + actionHandler is RemoteActions || + whooshLink.isStarted.value) Container( margin: const EdgeInsets.only(bottom: 8.0), width: double.infinity, @@ -230,7 +241,7 @@ class _DevicePageState extends State with WidgetsBindingObserver { child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Text( - 'Remote Devices', + 'Remote Connections', style: TextStyle(fontWeight: FontWeight.bold), ), ), @@ -239,6 +250,8 @@ class _DevicePageState extends State with WidgetsBindingObserver { (device) => device.showInformation(context), ), + if (whooshLink.isStarted.value) LinkDevice('').showInformation(context), + if (actionHandler is RemoteActions) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -259,53 +272,6 @@ class _DevicePageState extends State with WidgetsBindingObserver { ), ], ), - - if (connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected)) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Warning( - 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().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'), - ), - if (kDebugMode) - TextButton( - onPressed: () { - (connection.bluetoothDevices.first as ZwiftClickV2).test(); - }, - child: Text('Test'), - ), - ], - ), - ], - ), - ), ], ), ), diff --git a/lib/pages/requirements.dart b/lib/pages/requirements.dart index 1851839..1f641cd 100644 --- a/lib/pages/requirements.dart +++ b/lib/pages/requirements.dart @@ -84,7 +84,9 @@ class _RequirementsPageState extends State with WidgetsBinding if (_requirements[step].status && _requirements[step] is! TargetRequirement) { return; } - final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step; + final hasEarlierIncomplete = + _requirements.indexWhere((req) => !req.status) != -1 && + _requirements.indexWhere((req) => !req.status) < step; if (hasEarlierIncomplete) { return; } diff --git a/lib/utils/actions/android.dart b/lib/utils/actions/android.dart index e20558a..bfba135 100644 --- a/lib/utils/actions/android.dart +++ b/lib/utils/actions/android.dart @@ -53,7 +53,7 @@ class AndroidActions extends BaseActions { try { await accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp); } on PlatformException catch (e) { - return "Failed to perform touch action. Please get in contact with Jonas.\n${e.message}"; + return "Accessibility Service not working. Follow instructions at https://dontkillmyapp.com/"; } return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp ? "click" diff --git a/lib/utils/actions/remote.dart b/lib/utils/actions/remote.dart index 993b5cf..9e1d4b6 100644 --- a/lib/utils/actions/remote.dart +++ b/lib/utils/actions/remote.dart @@ -30,7 +30,7 @@ class RemoteActions extends BaseActions { if (keyPair.inGameAction != null && whooshLink.isConnected.value) { return whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue); } else if (!(actionHandler as RemoteActions).isConnected) { - return 'Not connected to a device'; + return 'Not connected to a ${settings.getLastTarget()?.name ?? 'remote'} device'; } if (keyPair.physicalKey != null && keyPair.touchPosition == Offset.zero) { diff --git a/lib/utils/requirements/multi.dart b/lib/utils/requirements/multi.dart index 631bdfc..cb0a572 100644 --- a/lib/utils/requirements/multi.dart +++ b/lib/utils/requirements/multi.dart @@ -89,25 +89,24 @@ enum Target { ), iPad( title: 'iPad', - description: 'Remotely control any trainer app on an iPad by acting as a Mouse', + description: 'Remotely control any trainer app on an iPad by acting as a Mouse, or directly via MyWhoosh Link', icon: Icons.settings_remote_outlined, - isBeta: true, ), android( title: 'Android Device', - description: 'Remotely control any trainer app on an Android device', + description: 'Remotely control any trainer app on another Android device, or directly via MyWhoosh Link', icon: Icons.settings_remote_outlined, isBeta: true, ), macOS( title: 'Mac', - description: 'Remotely control any trainer app on a Mac', + description: 'Remotely control any trainer app on another Mac, or directly via MyWhoosh Link', icon: Icons.settings_remote_outlined, isBeta: true, ), windows( title: 'Windows PC', - description: 'Remotely control any trainer app on a Windows PC', + description: 'Remotely control any trainer app on another Windows PC, or directly via MyWhoosh Link', icon: Icons.settings_remote_outlined, isBeta: true, ); @@ -183,7 +182,7 @@ class TargetRequirement extends PlatformRequirement { Row( children: [ Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)), - if (target.isBeta) BetaPill(), + if (target.isBeta || (!Platform.isIOS && target == Target.iPad)) BetaPill(), ], ), Text( diff --git a/lib/utils/requirements/remote.dart b/lib/utils/requirements/remote.dart index 4557faf..2fde214 100644 --- a/lib/utils/requirements/remote.dart +++ b/lib/utils/requirements/remote.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide ConnectionState; import 'package:permission_handler/permission_handler.dart'; import 'package:swift_control/main.dart'; +import 'package:swift_control/pages/device.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'; @@ -92,6 +93,11 @@ class RemoteRequirement extends PlatformRequirement { return; } } + if (kDebugMode) { + print('Continuing'); + return; + } + while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) { print('Waiting for peripheral manager to be powered on...'); if (settings.getLastTarget() == Target.thisDevice) { @@ -99,7 +105,6 @@ class RemoteRequirement extends PlatformRequirement { } await Future.delayed(Duration(seconds: 1)); } - if (!_isServiceAdded) { await Future.delayed(Duration(seconds: 1)); final reportMapDataAbsolute = Uint8List.fromList([ @@ -318,6 +323,30 @@ class _PairWidgetState extends State<_PairWidget> { if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()), ], ), + ElevatedButton( + onPressed: () async { + Navigator.push( + context, + MaterialPageRoute( + builder: (c) => DevicePage(), + settings: RouteSettings(name: '/device'), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Use MyWhoosh Link only'), + Text( + 'No pairing required, connect directly via MyWhoosh Link.', + style: TextStyle(fontSize: 10, color: Colors.black87), + ), + ], + ), + ), + ), if (_isAdvertising) ...[ TextButton( onPressed: () { diff --git a/lib/widgets/changelog_dialog.dart b/lib/widgets/changelog_dialog.dart index 5a66bb3..3479cd8 100644 --- a/lib/widgets/changelog_dialog.dart +++ b/lib/widgets/changelog_dialog.dart @@ -36,7 +36,7 @@ class ChangelogDialog extends StatelessWidget { static Future showIfNeeded(BuildContext context, String currentVersion, String? lastSeenVersion) async { // Show dialog if this is a new version - if (lastSeenVersion != currentVersion || true) { + if (lastSeenVersion != currentVersion) { try { final entry = await rootBundle.loadString('CHANGELOG.md'); if (context.mounted) { diff --git a/lib/widgets/keymap_explanation.dart b/lib/widgets/keymap_explanation.dart index 341d19b..b3647cd 100644 --- a/lib/widgets/keymap_explanation.dart +++ b/lib/widgets/keymap_explanation.dart @@ -94,27 +94,33 @@ class _KeymapExplanationState extends State { for (final keyPair in availableKeypairs) ...[ TableRow( children: [ - Padding( - padding: const EdgeInsets.all(6), - child: Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (actionHandler.supportedApp is! CustomApp) - if (keyPair.buttons.filter((b) => allAvailableButtons.contains(b)).isEmpty) - Text('No button assigned for your connected device') + TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: Container( + padding: const EdgeInsets.all(6), + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (actionHandler.supportedApp is! CustomApp) + if (keyPair.buttons.filter((b) => allAvailableButtons.contains(b)).isEmpty) + Text('No button assigned for your connected device') + else + for (final button in keyPair.buttons.filter((b) => allAvailableButtons.contains(b))) + IntrinsicWidth(child: ButtonWidget(button: button)) else - for (final button in keyPair.buttons.filter((b) => allAvailableButtons.contains(b))) - IntrinsicWidth(child: ButtonWidget(button: button)) - else - for (final button in keyPair.buttons) IntrinsicWidth(child: ButtonWidget(button: button)), - ], + for (final button in keyPair.buttons) IntrinsicWidth(child: ButtonWidget(button: button)), + ], + ), ), ), - Padding( - padding: const EdgeInsets.all(6), - child: _ButtonEditor(keyPair: keyPair, onUpdate: widget.onUpdate), + TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: Padding( + padding: const EdgeInsets.all(6), + child: _ButtonEditor(keyPair: keyPair, onUpdate: widget.onUpdate), + ), ), ], ), @@ -296,43 +302,39 @@ class _ButtonEditor extends StatelessWidget { ), ]; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (keyPair.buttons.isNotEmpty && - (keyPair.physicalKey != null || keyPair.touchPosition != Offset.zero || keyPair.inGameAction != null)) - Expanded( - child: KeypairExplanation( - keyPair: keyPair, - ), - ) - else - Expanded(child: Text('No action assigned')), + return Container( + constraints: BoxConstraints(minHeight: kMinInteractiveDimension - 6), + padding: EdgeInsets.only(right: actionHandler.supportedApp is CustomApp ? 4 : 0), + child: PopupMenuButton( + itemBuilder: (c) => actions, + enabled: actionHandler.supportedApp is CustomApp, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (keyPair.buttons.isNotEmpty && + (keyPair.physicalKey != null || keyPair.touchPosition != Offset.zero || keyPair.inGameAction != null)) + Expanded( + child: KeypairExplanation( + keyPair: keyPair, + ), + ) + else + Expanded(child: Text('No action assigned')), - if (actionHandler.supportedApp is CustomApp) - PopupMenuButton( - enabled: true, - itemBuilder: (context) => [ - if (actions.length > 1) ...actions, - ], - onSelected: (key) { - keyPair.physicalKey = key; - keyPair.logicalKey = null; - - onUpdate(); - }, - icon: Icon(Icons.edit), - ) - else - IconButton( - onPressed: () async { - final currentProfile = actionHandler.supportedApp!.name; - await KeymapManager().duplicate(context, currentProfile); - onUpdate(); - }, - icon: Icon(Icons.edit), - ), - ], + if (actionHandler.supportedApp is! CustomApp) + IconButton( + onPressed: () async { + final currentProfile = actionHandler.supportedApp!.name; + await KeymapManager().duplicate(context, currentProfile); + onUpdate(); + }, + icon: Icon(Icons.edit), + ) + else + Icon(Icons.edit, size: 14), + ], + ), + ), ); } } diff --git a/lib/widgets/scan.dart b/lib/widgets/scan.dart index 96b415e..5b584e4 100644 --- a/lib/widgets/scan.dart +++ b/lib/widgets/scan.dart @@ -2,7 +2,6 @@ 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'; class ScanWidget extends StatefulWidget { const ScanWidget({super.key}); @@ -67,7 +66,6 @@ class _ScanWidgetState extends State { }, child: const Text("Show Troubleshooting Guide"), ), - SmallProgressIndicator(), SizedBox(), ], );