diff --git a/lib/src/cli/commands/devices.dart b/lib/src/cli/commands/devices.dart index fc0363c..320aefd 100644 --- a/lib/src/cli/commands/devices.dart +++ b/lib/src/cli/commands/devices.dart @@ -410,6 +410,7 @@ class DevicesAddCommand extends FlutterpiCommand { usesDummyDisplayArg(); usesSshRemoteNonOptionArg(); usesFilesystemLayoutArg(); + usesRotationArg(); } @override @@ -500,6 +501,7 @@ class DevicesAddCommand extends FlutterpiCommand { useDummyDisplay: useDummyDisplay, dummyDisplaySize: dummyDisplaySize, filesystemLayout: fsLayout, + rotation: rotation?.toInt(), ), ); diff --git a/lib/src/cli/flutterpi_command.dart b/lib/src/cli/flutterpi_command.dart index 51da8c6..cabdc2e 100644 --- a/lib/src/cli/flutterpi_command.dart +++ b/lib/src/cli/flutterpi_command.dart @@ -135,6 +135,15 @@ mixin FlutterpiCommandMixin on fl.FlutterCommand { ); } + void usesRotationArg() { + argParser.addOption( + 'rotation', + help: 'The rotation of the display in degrees. (0, 90, 180, 270)', + valueHelp: 'degrees', + allowed: ['0', '90', '180', '270'], + ); + } + (int, int)? get displaySize { final size = stringArg('display-size'); if (size == null) { @@ -232,6 +241,28 @@ mixin FlutterpiCommandMixin on fl.FlutterCommand { return remote.contains('@') ? remote.split('@').first : null; } + int? get rotation { + final rotationArg = stringArg('rotation'); + if (rotationArg == null) { + return null; + } + + switch (rotationArg) { + case '0': + return 0; + case '90': + return 90; + case '180': + return 180; + case '270': + return 270; + default: + usageException( + 'Invalid --rotation: Expected one of "0", "90", "180", or "270".', + ); + } + } + final _contextOverrides = {}; void addContextOverride(dynamic Function() fn) { diff --git a/lib/src/config.dart b/lib/src/config.dart index 704ddad..edeb598 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -13,6 +13,7 @@ class DeviceConfigEntry { this.useDummyDisplay = false, this.dummyDisplaySize, this.filesystemLayout = FilesystemLayout.flutterPi, + this.rotation, }); final String id; @@ -24,6 +25,7 @@ class DeviceConfigEntry { final bool useDummyDisplay; final (int, int)? dummyDisplaySize; final FilesystemLayout filesystemLayout; + final int? rotation; static DeviceConfigEntry fromMap(Map map) { return DeviceConfigEntry( @@ -45,6 +47,7 @@ class DeviceConfigEntry { String string => FilesystemLayout.fromString(string), _ => FilesystemLayout.flutterPi, }, + rotation: (map['rotation'] as num?)?.toInt(), ); } @@ -63,6 +66,7 @@ class DeviceConfigEntry { 'dummyDisplaySize': [width, height], if (filesystemLayout != FilesystemLayout.flutterPi) 'filesystemLayout': filesystemLayout.toString(), + if (rotation case int rotation) 'rotation': rotation, }; } @@ -82,7 +86,8 @@ class DeviceConfigEntry { devicePixelRatio == otherEntry.devicePixelRatio && useDummyDisplay == otherEntry.useDummyDisplay && dummyDisplaySize == otherEntry.dummyDisplaySize && - filesystemLayout == otherEntry.filesystemLayout; + filesystemLayout == otherEntry.filesystemLayout && + rotation == otherEntry.rotation; } @override @@ -96,6 +101,7 @@ class DeviceConfigEntry { useDummyDisplay, dummyDisplaySize, filesystemLayout, + rotation, ); @override diff --git a/lib/src/devices/flutterpi_ssh/device.dart b/lib/src/devices/flutterpi_ssh/device.dart index 14ef034..256020c 100644 --- a/lib/src/devices/flutterpi_ssh/device.dart +++ b/lib/src/devices/flutterpi_ssh/device.dart @@ -117,12 +117,21 @@ class FlutterpiArgs { this.useDummyDisplay = false, this.dummyDisplaySize, this.filesystemLayout = FilesystemLayout.flutterPi, - }); + this.rotation, + }) : assert( + rotation == null || + rotation == 0 || + rotation == 90 || + rotation == 180 || + rotation == 270, + 'Rotation must be one of: 0, 90, 180, 270 degrees if non-null.', + ); final (int, int)? explicitDisplaySizeMillimeters; final bool useDummyDisplay; final (int, int)? dummyDisplaySize; final FilesystemLayout filesystemLayout; + final int? rotation; } class FlutterpiSshDevice extends fl.Device { @@ -392,6 +401,7 @@ class FlutterpiSshDevice extends fl.Device { if (args.useDummyDisplay) '--dummy-display', if (args.dummyDisplaySize case (final width, final height)) '--dummy-display-size=$width,$height', + if (args.rotation != null) '--rotation=${args.rotation}', if (runtimeModeArg != null) runtimeModeArg, bundlePath, ...engineArgs, diff --git a/lib/src/devices/flutterpi_ssh/device_discovery.dart b/lib/src/devices/flutterpi_ssh/device_discovery.dart index 5ddfccf..1fd78f7 100644 --- a/lib/src/devices/flutterpi_ssh/device_discovery.dart +++ b/lib/src/devices/flutterpi_ssh/device_discovery.dart @@ -47,6 +47,7 @@ class FlutterpiSshDeviceDiscovery extends PollingDeviceDiscovery { useDummyDisplay: configEntry.useDummyDisplay, dummyDisplaySize: configEntry.dummyDisplaySize, filesystemLayout: configEntry.filesystemLayout, + rotation: configEntry.rotation, ), ); } diff --git a/test/commands/devices_test.dart b/test/commands/devices_test.dart index baec59d..61168ac 100644 --- a/test/commands/devices_test.dart +++ b/test/commands/devices_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:args/command_runner.dart'; import 'package:file/memory.dart'; import 'package:file/src/interface/file_system.dart'; @@ -783,6 +784,185 @@ void main() { }); }); + group('rotation', () { + test('default (0 degrees)', () async { + var addDeviceWasCalled = false; + config + ..addDeviceFn = (entry) { + expect( + entry, + src.DeviceConfigEntry( + id: 'test-device', + sshExecutable: null, + sshRemote: 'test-device', + remoteInstallPath: null, + rotation: 0, + ), + ); + addDeviceWasCalled = true; + } + ..containsDeviceFn = (id) { + return false; + }; + + await _runInTestContext(() async { + await runner.run(['devices', 'add', 'test-device', '--rotation=0']); + }); + + expect( + addDeviceWasCalled, + isTrue, + reason: 'addDeviceFn should have been called', + ); + }); + + test('90 degrees', () async { + var addDeviceWasCalled = false; + config + ..addDeviceFn = (entry) { + expect( + entry, + src.DeviceConfigEntry( + id: 'test-device', + sshExecutable: null, + sshRemote: 'test-device', + remoteInstallPath: null, + filesystemLayout: FilesystemLayout.flutterPi, + rotation: 90, + ), + ); + addDeviceWasCalled = true; + } + ..containsDeviceFn = (id) { + return false; + }; + + await _runInTestContext(() async { + await runner.run(['devices', 'add', 'test-device', '--rotation=90']); + }); + expect( + addDeviceWasCalled, + isTrue, + reason: 'addDeviceFn should have been called', + ); + }); + + test('180 degrees', () async { + var addDeviceWasCalled = false; + config + ..addDeviceFn = (entry) { + expect( + entry, + src.DeviceConfigEntry( + id: 'test-device', + sshExecutable: null, + sshRemote: 'test-device', + remoteInstallPath: null, + filesystemLayout: FilesystemLayout.flutterPi, + rotation: 180, + ), + ); + addDeviceWasCalled = true; + } + ..containsDeviceFn = (id) { + return false; + }; + + await _runInTestContext(() async { + await runner.run(['devices', 'add', 'test-device', '--rotation=180']); + }); + + expect( + addDeviceWasCalled, + isTrue, + reason: 'addDeviceFn should have been called', + ); + }); + + test('270 degrees', () async { + var addDeviceWasCalled = false; + config + ..addDeviceFn = (entry) { + expect( + entry, + src.DeviceConfigEntry( + id: 'test-device', + sshExecutable: null, + sshRemote: 'test-device', + remoteInstallPath: null, + filesystemLayout: FilesystemLayout.flutterPi, + rotation: 270, + ), + ); + addDeviceWasCalled = true; + } + ..containsDeviceFn = (id) { + return false; + }; + + await _runInTestContext(() async { + await runner.run(['devices', 'add', 'test-device', '--rotation=270']); + }); + + expect( + addDeviceWasCalled, + isTrue, + reason: 'addDeviceFn should have been called', + ); + }); + + test('without rotation flag (should be null)', () async { + var addDeviceWasCalled = false; + config + ..addDeviceFn = (entry) { + expect( + entry, + src.DeviceConfigEntry( + id: 'test-device', + sshExecutable: null, + sshRemote: 'test-device', + remoteInstallPath: null, + filesystemLayout: FilesystemLayout.flutterPi, + rotation: null, // No rotation specified + ), + ); + addDeviceWasCalled = true; + } + ..containsDeviceFn = (id) { + return false; + }; + + await _runInTestContext(() async { + await runner.run(['devices', 'add', 'test-device']); + }); + + expect( + addDeviceWasCalled, + isTrue, + reason: 'addDeviceFn should have been called', + ); + }); + test('179 degrees (should fail - invalid value)', () async { + config + ..addDeviceFn = (entry) { + fail( + 'addDeviceFn should not have been called with invalid rotation', + ); + } + ..containsDeviceFn = (id) { + return false; + }; + + await _runInTestContext(() async { + expect( + () async => await runner + .run(['devices', 'add', 'test-device', '--rotation=179']), + throwsA(isA()), + ); + }); + }); + }); + group('diagnostics', () { test('attempts connecting to new device', () async { var tryConnectWasCalled = false;